mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62eb8cbf42 | ||
|
|
f56cc29afe | ||
|
|
0a50705bb5 | ||
|
|
24253d6406 | ||
|
|
17b113091c | ||
|
|
e47378614a | ||
|
|
3a190e6a9e | ||
|
|
c9e22779a1 | ||
|
|
7c278f1c55 | ||
|
|
0ce1eb88be | ||
|
|
ceea6f2758 | ||
|
|
6d911712a6 | ||
|
|
69a8b1a0a2 | ||
|
|
24f390c332 |
No files matched your search
@@ -82,8 +82,6 @@ on:
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/tray-panel-layout.live.test.cjs"
|
||||
- "scripts/serial-named-pipe.live.test.cjs"
|
||||
- "scripts/fixtures/serial/**"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
@@ -253,10 +251,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test Windows serial pipe through native Electron
|
||||
if: matrix.name == 'windows'
|
||||
run: npx electron scripts/serial-named-pipe.live.test.cjs
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
@@ -49,12 +49,6 @@ jobs:
|
||||
- name: Verify Codex App Server schema
|
||||
run: npm run check:codex-app-server-schema
|
||||
|
||||
- name: Verify plugin contract and runtime boundaries
|
||||
run: |
|
||||
npm run check:plugin-contract
|
||||
npm run test:plugin-runtime
|
||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
@@ -97,9 +91,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Exercise serial named pipe transport on Windows
|
||||
run: node --test electron/bridges/serialNamedPipe.test.cjs
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
|
||||
@@ -106,13 +106,10 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime
|
||||
## Plugin host runtime (internal preview)
|
||||
|
||||
The plugin host under `electron/plugins/` exposes browser-only packages in normal
|
||||
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
|
||||
granting their permissions. Admission and default placement share
|
||||
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
|
||||
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
|
||||
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
|
||||
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
|
||||
types still come only from `packages/plugin-contract/schema/`; do not add a
|
||||
second private RPC shape when extending the host.
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -87,7 +86,6 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
|
||||
+10
-16
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
|
||||
---
|
||||
@@ -69,14 +63,14 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
|
||||
---
|
||||
@@ -159,10 +153,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
|
||||
|
||||
@@ -171,23 +165,21 @@ The main window is designed for long-running SSH workflows: quick access to sess
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
|
||||
|
||||
|
||||
## Monitoring View
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
|
||||
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.
|
||||
|
||||
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
|
||||
<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="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
|
||||
|
||||
@@ -197,7 +189,7 @@ Disk I/O shows aggregate read/write throughput for Linux hardware-backed block d
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-16
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-16
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
|
||||
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -9,11 +8,6 @@ import {
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import {
|
||||
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
|
||||
getSettingsChromeSnapshot,
|
||||
publishSettingsChromeSnapshot,
|
||||
} from './state/settingsChromeStore.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -835,201 +829,3 @@ for (const scenario of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const shiftedActions: string[] = [];
|
||||
let shiftedPrevented = false;
|
||||
let shiftedStopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
shiftedActions.push(action);
|
||||
return action === 'toggleHostTree';
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { shiftedPrevented = true; },
|
||||
stopPropagation: () => { shiftedStopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(shiftedActions, ['toggleHostTree']);
|
||||
assert.equal(shiftedPrevented, true);
|
||||
assert.equal(shiftedStopped, true);
|
||||
|
||||
const plainActions: string[] = [];
|
||||
let plainPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
plainActions.push(action);
|
||||
return undefined;
|
||||
},
|
||||
hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: scheme === 'pc',
|
||||
metaKey: scheme === 'mac',
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { plainPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(plainActions, ['broadcast']);
|
||||
assert.equal(plainPrevented, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
actions.push(action);
|
||||
return false;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(actions, ['toggleHostTree']);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
|
||||
const monaco = new FakeMonacoHTMLElement();
|
||||
const monacoActions: string[] = [];
|
||||
let monacoPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
monacoActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: monaco,
|
||||
composedPath: () => [monaco],
|
||||
preventDefault: () => { monacoPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(monacoActions, ['toggleHostTree']);
|
||||
assert.equal(monacoPrevented, true);
|
||||
|
||||
const input = new FakeInputHTMLElement();
|
||||
const inputActions: string[] = [];
|
||||
let inputPrevented = false;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
inputActions.push(action);
|
||||
return true;
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
target: input,
|
||||
composedPath: () => [input],
|
||||
preventDefault: () => { inputPrevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(inputActions, []);
|
||||
assert.equal(inputPrevented, false);
|
||||
});
|
||||
|
||||
test('toggleHostTree uses the host list toggle only while that list can show', () => {
|
||||
const previous = getSettingsChromeSnapshot();
|
||||
const calls: string[] = [];
|
||||
const toggleHostTree = () => { calls.push('toggle'); };
|
||||
const event = {} as KeyboardEvent;
|
||||
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
editorTabs: [],
|
||||
orderedTabs,
|
||||
sessions: [{ id: 'session-1' }],
|
||||
settings: {},
|
||||
toEditorTabId: (id: string) => id,
|
||||
toggleHostTree,
|
||||
workspaces: [{ id: 'workspace-1' }],
|
||||
});
|
||||
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
|
||||
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
|
||||
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test('host list shortcut calls the same store toggle as the toolbar button', () => {
|
||||
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
|
||||
import { isHostTreeWorkTabSurface } from './workTabSurface';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
@@ -278,9 +276,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
|
||||
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
|
||||
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
|
||||
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
|
||||
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
|
||||
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
|
||||
|
||||
if (
|
||||
(isFormElement || isMonacoElement)
|
||||
@@ -288,7 +283,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
&& e.key !== 'Escape'
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !(isMonacoElement && isHostTreeHotkey)
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
@@ -328,10 +322,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unavailable action must leave the chord to the terminal.
|
||||
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
|
||||
// when the sidebar cannot show. Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
|
||||
// An unavailable broadcast action must leave the chord to the terminal
|
||||
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast') {
|
||||
if (executeHotkeyAction(binding.action, e) === true) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -808,7 +801,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -1004,18 +997,6 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'toggleHostTree': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!isHostTreeWorkTabSurface({
|
||||
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
|
||||
activeTabId: currentId,
|
||||
orderedTabs: orderedTabs ?? [],
|
||||
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
|
||||
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
|
||||
}) || typeof toggleHostTree !== 'function') return false;
|
||||
toggleHostTree();
|
||||
return true;
|
||||
}
|
||||
case 'broadcast': {
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
@@ -1164,7 +1145,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1184,7 +1164,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
const protocol = resolveEffectiveTerminalProtocol(effectiveHost);
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost, { hidden });
|
||||
if (!sessionId) return null;
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1205,7 +1184,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -1221,10 +1200,7 @@ export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, se
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
if (matchingLog) {
|
||||
// The IndexedDB replay write commits asynchronously. Waiting here keeps
|
||||
// a crash during session teardown from dropping the newest capture any
|
||||
// more often than the old synchronous localStorage write.
|
||||
await updateConnectionLog(matchingLog.id, {
|
||||
updateConnectionLog(matchingLog.id, {
|
||||
endTime: Date.now(),
|
||||
terminalData: data,
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ConnectionLog, Host } from '../../domain/models';
|
||||
import { createLocalShellHost } from '../../domain/localShellHost';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { handleConnectToHostImpl, handleTerminalDataCaptureImpl } from './AppHandlers';
|
||||
|
||||
test('workspace host launch records resolved metadata and associates replay with its acknowledged session', async () => {
|
||||
const host = createLocalShellHost({ id: 'saved-local', label: 'Work shell' });
|
||||
const connectionLogs: ConnectionLog[] = [];
|
||||
let appendedHost: Host | undefined;
|
||||
const getCtx = () => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [{ id: 'zsh', name: 'Z shell', command: '/bin/zsh' }],
|
||||
terminalSettings: { localShell: 'zsh', localShellArgs: ['-l'], localStartDir: '/tmp/work' },
|
||||
resolveShellSetting: () => ({ command: '/bin/zsh', args: ['-l'] }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'ignored-ssh-user' }),
|
||||
// The App adapts this creation callback to appendHostToWorkspaceConfirmed.
|
||||
connectToHost: (candidate: Host) => {
|
||||
assert.equal(connectionLogs.length, 0, 'do not record history before insertion is acknowledged');
|
||||
appendedHost = candidate;
|
||||
return 'workspace-session';
|
||||
},
|
||||
addConnectionLog: (entry: Omit<ConnectionLog, 'id'>) => {
|
||||
connectionLogs.push({ ...entry, id: 'workspace-log' });
|
||||
},
|
||||
});
|
||||
assert.equal(handleConnectToHostImpl(getCtx, host), 'workspace-session');
|
||||
assert.equal(appendedHost?.localShell, '/bin/zsh');
|
||||
assert.deepEqual(appendedHost?.localShellArgs, ['-l']);
|
||||
assert.equal(appendedHost?.localStartDir, '/tmp/work');
|
||||
assert.equal(connectionLogs.length, 1);
|
||||
assert.equal(connectionLogs[0].sessionId, 'workspace-session');
|
||||
assert.equal(connectionLogs[0].hostId, host.id);
|
||||
assert.equal(connectionLogs[0].hostLabel, 'Work shell');
|
||||
assert.equal(connectionLogs[0].username, 'local-user');
|
||||
assert.equal(connectionLogs[0].protocol, 'local');
|
||||
assert.equal(connectionLogs[0].localHostname, 'local-machine');
|
||||
const updates: Array<{ id: string; terminalData: string }> = [];
|
||||
await handleTerminalDataCaptureImpl(() => ({
|
||||
IS_DEV: false,
|
||||
sessions: [{ id: 'workspace-session', hostname: 'localhost' }],
|
||||
// A newer, same-host log must not steal this session's capture.
|
||||
connectionLogs: [...connectionLogs, { ...connectionLogs[0], id: 'other-log', sessionId: 'other-session', startTime: Date.now() + 100 }],
|
||||
selectConnectionLogForTerminalDataCapture,
|
||||
updateConnectionLog: async (id: string, entry: { terminalData: string }) => { updates.push({ id, terminalData: entry.terminalData }); },
|
||||
}), 'workspace-session', 'captured terminal output');
|
||||
assert.deepEqual(updates, [{ id: 'workspace-log', terminalData: 'captured terminal output' }]);
|
||||
});
|
||||
|
||||
for (const protocol of ['local', 'ssh', 'serial'] as const) {
|
||||
test(`refused workspace insertion creates no ${protocol} connection log`, () => {
|
||||
let logs = 0;
|
||||
const host = { id: 'host', label: 'Host', hostname: 'target', protocol } as Host;
|
||||
const sessionId = handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (candidate: Host) => candidate,
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-machine' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'remote-user' }),
|
||||
connectToHost: () => null,
|
||||
addConnectionLog: () => { logs += 1; },
|
||||
}), host);
|
||||
assert.equal(sessionId, null);
|
||||
assert.equal(logs, 0);
|
||||
});
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
@@ -71,7 +70,6 @@ import {
|
||||
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import {
|
||||
markExternalMcpStartupReady,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
@@ -256,7 +254,6 @@ export function AppSideEffects() {
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
@@ -1129,7 +1126,6 @@ export function AppSideEffects() {
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleHostTree: () => terminalHostTreeStore.toggle(),
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
@@ -1460,7 +1456,7 @@ export function AppSideEffects() {
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false, workspaceId?: string) => {
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
@@ -1478,9 +1474,7 @@ export function AppSideEffects() {
|
||||
: resolveEffectiveHost;
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost: workspaceId
|
||||
? (candidate: Host) => appendHostToWorkspaceConfirmed(workspaceId, candidate)
|
||||
: connectToHost,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
@@ -1491,7 +1485,7 @@ export function AppSideEffects() {
|
||||
systemInfoRef,
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, appendHostToWorkspaceConfirmed, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1501,14 +1495,12 @@ export function AppSideEffects() {
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||
if (target.error) return { ok: false as const, error: target.error };
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
// host_open always opens a visible tab, as documented.
|
||||
const hidden = isExternalMcpCall && readExternalMcpSilentSessions();
|
||||
const sessionId = handleConnectToHost(host, true, hidden, target.workspaceId);
|
||||
const sessionId = handleConnectToHost(host, true, hidden);
|
||||
if (!sessionId) {
|
||||
return { ok: false as const, error: `Failed to open host "${host.id}".` };
|
||||
}
|
||||
@@ -1517,8 +1509,8 @@ export function AppSideEffects() {
|
||||
if (readExternalMcpFocusOnHostOpen()) {
|
||||
void netcattyBridge.get()?.openMainWindow?.();
|
||||
}
|
||||
return { ok: true as const, sessionId, host, workspaceId: target.workspaceId };
|
||||
}, [handleConnectToHost, workspaces]);
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
|
||||
@@ -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, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, 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,12 +784,8 @@ 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}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// A separate renderer thread lets the real main-process timeout run while
|
||||
// window.confirm is synchronously blocked, just like Electron's native dialog.
|
||||
require("tsx/cjs");
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const React = require("react");
|
||||
const { act, create } = require("react-test-renderer");
|
||||
const { useAppStartupEffects } = require("../useAppStartupEffects.ts");
|
||||
const { sftpTransferCenterStore } = require("../../state/sftpTransferCenterStore.ts");
|
||||
const { netcattyBridge } = require("../../../infrastructure/services/netcattyBridge.ts");
|
||||
const { createPreloadApi } = require("../../../electron/preload/api.cjs");
|
||||
const signal = new Int32Array(workerData.signal);
|
||||
let resumes = 0;
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
global.window = {
|
||||
confirm() {
|
||||
parentPort.postMessage({ type: "prompt" });
|
||||
Atomics.wait(signal, 0, 0, 10000);
|
||||
return workerData.accept;
|
||||
},
|
||||
};
|
||||
sftpTransferCenterStore.getSnapshot = () => ({ tasks: [{ id: "held-prompt", status: "transferring" }] });
|
||||
sftpTransferCenterStore.pause = async () => {};
|
||||
sftpTransferCenterStore.resume = async () => { resumes++; };
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel, payload) { parentPort.postMessage({ type: "reply", channel, payload }); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
netcattyBridge.get = () => ({
|
||||
onCheckDirtyEditors(callback) {
|
||||
return preload.onCheckDirtyEditors((request) => {
|
||||
void callback(request).then(() => parentPort.postMessage({ type: "done", resumes }));
|
||||
});
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
});
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key) => key });
|
||||
return null;
|
||||
}
|
||||
parentPort.on("message", ({ channel, payload }) => ipcRenderer.emit(channel, {}, payload));
|
||||
void act(async () => { create(React.createElement(Probe)); })
|
||||
.then(() => parentPort.postMessage({ type: "ready" }));
|
||||
@@ -205,11 +205,7 @@ 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,
|
||||
|
||||
@@ -59,7 +59,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -71,12 +70,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -144,7 +139,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
@@ -156,12 +150,8 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -173,7 +163,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
@@ -192,9 +181,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -214,9 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -165,12 +165,8 @@ test('vault init cancels the superseded StrictMode effect before publishing read
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 20000);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
|
||||
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
|
||||
);
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- Electron boundary doubles exercise production modules. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects.ts';
|
||||
import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore.ts';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge.ts';
|
||||
const require = createRequire(import.meta.url);
|
||||
const React = require('react');
|
||||
const { act, create } = require('react-test-renderer');
|
||||
const { createMainWindowApi } = require('../../electron/bridges/windowManager/mainWindow.cjs');
|
||||
const { queryDirtyEditors } = require('../../electron/bridges/dirtyEditorGuard.cjs');
|
||||
const { createPreloadApi } = require('../../electron/preload/api.cjs');
|
||||
|
||||
// No Electron UI, sockets, real transfers, or preferences. Only the network
|
||||
// pause completion is held. Actual renderer hook, window close callback, and
|
||||
// dirty-query helper run together with the actual 5000ms production deadline.
|
||||
for (const slowPause of [true, false]) test(`${slowPause ? 'slow pause refuses close at the production deadline' : 'control: fast pause preserves Cancel'}`, { timeout: 8000 }, async t => {
|
||||
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const oldAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let confirmations = 0, reports = 0, resumeCalls = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { confirm() { confirmations++; return false; } } });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let renderer: any, releasePause: any;
|
||||
const queryPromises: Promise<void>[] = [];
|
||||
const pause = new Promise<void>(resolve => { releasePause = resolve; });
|
||||
if (!slowPause) releasePause();
|
||||
const ipcMain = new EventEmitter();
|
||||
let win: any;
|
||||
t.after(async () => {
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await act(async () => renderer?.unmount());
|
||||
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (oldAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', oldAct); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, 'getSnapshot', () => ({ tasks: [{ id: 'slow-upload', status: 'transferring' }] }) as any);
|
||||
t.mock.method(sftpTransferCenterStore, 'pause', () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, 'resume', async () => { resumeCalls++; });
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), {
|
||||
send(channel: string, payload: any) { reports++; ipcMain.emit(channel, { sender: win.webContents }, payload); },
|
||||
});
|
||||
const preload = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
onCheckDirtyEditors(callback: any) {
|
||||
return preload.onCheckDirtyEditors((request: any) => { queryPromises.push(callback(request)); });
|
||||
},
|
||||
reportDirtyEditorsResult: preload.reportDirtyEditorsResult,
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key } as any);
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
class Window extends EventEmitter {
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71, isDestroyed: () => this.destroyed, isCrashed: () => false,
|
||||
setWindowOpenHandler() {}, openDevTools() {},
|
||||
send(channel: string, payload: any) { ipcRenderer.emit(channel, {}, payload); },
|
||||
});
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit('close', event); if (!event.prevented) { this.destroyed = true; this.emit('closed'); } }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: 'light', isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: 'code', THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname: '/unused/electron/bridges', URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl: (url: string) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {},
|
||||
hasEditorTabsForSource: () => false, closeEditorTabsForSource: async () => ({ success: true }),
|
||||
queryDirtyEditors(wc: any, timeout: number, options: any) {
|
||||
assert.equal(timeout, 5000, 'real close callback supplies the 5-second timeout');
|
||||
return queryDirtyEditors(wc, timeout, options);
|
||||
},
|
||||
});
|
||||
win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
|
||||
preload: '/tmp/unused-preload.cjs', devServerUrl: 'http://localhost:5173', isDev: true, isMac: false,
|
||||
electronDir: '/unused/electron', registerAsMainWindow: true,
|
||||
});
|
||||
win.close();
|
||||
assert.equal(win.destroyed, false, 'first close is correctly guarded');
|
||||
await new Promise(resolve => setTimeout(resolve, slowPause ? 5250 : 50));
|
||||
if (!slowPause) {
|
||||
assert.equal(confirmations, 1); assert.equal(reports, 2); assert.equal(resumeCalls, 1);
|
||||
assert.equal(win.destroyed, false, 'Cancel keeps owner alive when pause settles before deadline');
|
||||
console.log('QUIT_FAST_CONTROL', JSON.stringify({ ownerAlive: true, confirmations, reports, resumeCalls }));
|
||||
return;
|
||||
}
|
||||
assert.equal(confirmations, 0, 'slow pause prevents prompt');
|
||||
assert.equal(win.destroyed, false, 'deadline refuses close while the pause is pending');
|
||||
assert.equal(reports, 1, 'only the pending-transfer acknowledgement has been sent');
|
||||
assert.equal(ipcMain.listenerCount('app:dirty-editors-result'), 0, 'main query is settled, not hung');
|
||||
releasePause(); await Promise.allSettled(queryPromises);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(confirmations, 0, 'the expired request must not show a late prompt');
|
||||
assert.equal(win.destroyed, false);
|
||||
assert.equal(resumeCalls, 1);
|
||||
console.log('QUIT_TIMEOUT_REGRESSION', JSON.stringify({ ownerAlive: true, productionDeadlineMs: 5000, scaling: false, resumeCalls }));
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, once } = require("node:events");
|
||||
const { Worker } = require("node:worker_threads");
|
||||
const { queryDirtyEditors } = require("../../electron/bridges/dirtyEditorGuard.cjs");
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`native confirmation held beyond deadline: late ${accept ? "Accept" : "Cancel"} keeps the owner alive`, { timeout: 10000 }, async (t) => {
|
||||
const signal = new SharedArrayBuffer(4);
|
||||
const worker = new Worker(require.resolve("./__fixtures__/transferQuitRenderer.cjs"), {
|
||||
workerData: { accept, signal },
|
||||
});
|
||||
const ipcMain = new EventEmitter();
|
||||
const events = new EventEmitter();
|
||||
const replies = [];
|
||||
let promptShown = false;
|
||||
let closed = false;
|
||||
const wc = {
|
||||
isDestroyed: () => closed,
|
||||
isCrashed: () => false,
|
||||
send(channel, payload) { worker.postMessage({ channel, payload }); },
|
||||
};
|
||||
t.after(async () => { Atomics.store(new Int32Array(signal), 0, 1); Atomics.notify(new Int32Array(signal), 0); await worker.terminate(); });
|
||||
worker.on("message", (message) => {
|
||||
if (message.type === "reply") {
|
||||
replies.push(message.payload);
|
||||
ipcMain.emit(message.channel, { sender: wc }, message.payload);
|
||||
} else {
|
||||
if (message.type === "prompt") promptShown = true;
|
||||
events.emit(message.type, message);
|
||||
}
|
||||
});
|
||||
worker.on("error", (error) => events.emit("error", error));
|
||||
await once(events, "ready");
|
||||
const started = Date.now();
|
||||
const result = await queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
if (!result) closed = true;
|
||||
assert.equal(promptShown, true);
|
||||
assert.equal(result, "transfers");
|
||||
assert.equal(closed, false, "main refuses close while the renderer is blocked in its native prompt");
|
||||
assert.ok(Date.now() - started >= 4900, "use the unchanged five-second production deadline");
|
||||
assert.equal(ipcMain.listenerCount("app:dirty-editors-result"), 0, "main settled without hanging");
|
||||
const done = once(events, "done");
|
||||
Atomics.store(new Int32Array(signal), 0, 1);
|
||||
Atomics.notify(new Int32Array(signal), 0);
|
||||
const [completed] = await done;
|
||||
assert.equal(completed.resumes, 1);
|
||||
assert.equal(replies.at(-1).hasDirty, true, "an expired Accept cannot authorize a later close");
|
||||
assert.equal(replies.at(-1).reason, "transfers");
|
||||
assert.equal(closed, false);
|
||||
});
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
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";
|
||||
|
||||
const makeTask = (id: string, status: TransferTask["status"]): TransferTask => ({
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
totalBytes: 10,
|
||||
transferredBytes: 2,
|
||||
speed: 1,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
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 = {};
|
||||
let prompts = 0;
|
||||
g.window.confirm = () => { prompts++; return 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, [[true, "transfers"], [false, undefined]]);
|
||||
assert.deepEqual(calls, ["report", ...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"], [true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, ["report", ...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
// Dedicated resume promises can represent the whole transfer lifetime.
|
||||
// A second close must still prompt while those promises remain pending.
|
||||
await query();
|
||||
assert.equal(prompts, 2);
|
||||
assert.equal(resumeSettled, false);
|
||||
assert.equal(reports.length, 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["pause-rejected", "pause-expired", "prompt-expired"] as const) {
|
||||
test(`transfer quit refuses ${outcome} and resumes only the formerly active task`, async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
const replies: Array<{ dirty: boolean; reason?: string; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {
|
||||
confirm() { prompts++; now = 6000; return true; },
|
||||
} });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("running", "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async () => {
|
||||
if (outcome === "pause-rejected") throw new Error("pause failed");
|
||||
if (outcome === "pause-expired") now = 6000;
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string, options?: { requestId?: string; pending?: boolean }) => {
|
||||
replies.push({ dirty, reason, ...options });
|
||||
},
|
||||
}));
|
||||
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)); });
|
||||
await query({ requestId: outcome, deadline: 5000 });
|
||||
assert.deepEqual(replies, [
|
||||
{ dirty: true, reason: "transfers", requestId: outcome, pending: true },
|
||||
{ dirty: true, reason: "transfers", requestId: outcome },
|
||||
]);
|
||||
assert.equal(prompts, outcome === "prompt-expired" ? 1 : 0);
|
||||
assert.equal(resumes, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("a cancelled task can close after an expired stuck pause, and its late completion cannot resume it", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let cancelled = false;
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: cancelled ? 0 : 1, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", cancelled ? "cancelled" : "transferring")] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => pause);
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
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 first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
cancelled = true;
|
||||
await query({ requestId: "after-cancel", deadline: 11000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: false, requestId: "after-cancel" });
|
||||
const replyCount = replies.length;
|
||||
releasePause();
|
||||
await first;
|
||||
assert.equal(replies.length, replyCount, "superseded attempt must not reply again");
|
||||
assert.equal(prompts, 0);
|
||||
assert.equal(resumes, 0, "late completion must not undo the user's cancellation");
|
||||
});
|
||||
|
||||
test("retry Cancel preserves the expired attempt auto-resume ownership", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let now = 0;
|
||||
let status: TransferTask["status"] = "transferring";
|
||||
let prompts = 0;
|
||||
let resumes = 0;
|
||||
let releasePause!: () => void;
|
||||
const pause = new Promise<void>((resolve) => { releasePause = resolve; });
|
||||
const replies: Array<{ dirty: boolean; requestId?: string; pending?: boolean }> = [];
|
||||
let query!: (request: { requestId: string; deadline: number }) => Promise<void>;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { confirm() { prompts++; return false; } } });
|
||||
t.after(async () => {
|
||||
releasePause();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window");
|
||||
if (previousAct) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct); else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({ activeCount: status === "transferring" || status === "pausing" ? 1 : 0, queuedCount: 0, attentionCount: 0, tasks: [makeTask("stuck", status)] }));
|
||||
t.mock.method(sftpTransferCenterStore, "pause", () => { status = "pausing"; return pause; });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async () => { resumes++; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, _reason?: string, options?: { requestId?: string; pending?: boolean }) => replies.push({ dirty, ...options }),
|
||||
}));
|
||||
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 first = query({ requestId: "stuck", deadline: 5000 });
|
||||
await query({ requestId: "overlap", deadline: 5000 });
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "overlap" });
|
||||
now = 6000;
|
||||
const retry = query({ requestId: "retry", deadline: 11000 });
|
||||
status = "paused";
|
||||
releasePause();
|
||||
await Promise.all([first, retry]);
|
||||
assert.equal(prompts, 1);
|
||||
assert.equal(resumes, 1, "Cancel must restore work running before the first attempt");
|
||||
assert.deepEqual(replies.at(-1), { dirty: true, requestId: "retry" });
|
||||
});
|
||||
@@ -344,101 +344,42 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
}, [enabled, hasRuntimeTunnel, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
const transferQuitPendingRef = useRef<{ deadline: number; resumeTaskIds: Set<string> } | null>(null);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(async (request) => {
|
||||
const response = { requestId: request?.requestId };
|
||||
// Another close request must not start a second pause/prompt while the
|
||||
// first attempt is still pausing or awaiting a decision.
|
||||
const previousAttempt = transferQuitPendingRef.current;
|
||||
if (previousAttempt && Date.now() < previousAttempt.deadline) {
|
||||
bridge.reportDirtyEditorsResult?.(true, "transfers", response);
|
||||
return;
|
||||
}
|
||||
// An expired pause may never settle. Let a retry re-check current tasks
|
||||
// (including a user's cancellation), without granting the old attempt
|
||||
// permission to prompt or resume over that newer decision.
|
||||
let attempt: typeof previousAttempt = null;
|
||||
const unsub = bridge.onCheckDirtyEditors(async () => {
|
||||
// Always report SOMETHING so the main process doesn't time out for
|
||||
// 5 s on an unhandled exception. If we can't determine the state,
|
||||
// fail open — losing unsaved work is bad, but stranding the user
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
let reason: "editors" | "transfers" | undefined;
|
||||
// Captured before pause. Only transferring/queued rows were active
|
||||
// because of this prompt.
|
||||
let tasksToResume: { id: string }[] = [];
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) {
|
||||
reason = "editors";
|
||||
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
}
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
|
||||
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
|
||||
));
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
|| previousAttempt?.resumeTaskIds.has(task.id)
|
||||
));
|
||||
// A slow pause must never turn the main-process timeout into
|
||||
// permission to quit before the user has made a decision.
|
||||
hasDirty = true;
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
// A retry inherits the still-unfinished rows the expired attempt
|
||||
// auto-paused. Their current status may already be pausing/paused.
|
||||
attempt = {
|
||||
deadline: request?.deadline ?? Date.now() + 5000,
|
||||
resumeTaskIds: new Set(resumable.map((task) => task.id)),
|
||||
};
|
||||
transferQuitPendingRef.current = attempt;
|
||||
bridge.reportDirtyEditorsResult?.(true, reason, { ...response, pending: true });
|
||||
const paused = await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
if (transferQuitPendingRef.current !== attempt) return;
|
||||
const expired = () => request && Date.now() >= request.deadline;
|
||||
if (!expired() && paused.every((result) => result.status === "fulfilled")) {
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
// The native prompt can itself outlive the main-process deadline.
|
||||
// An expired acceptance is not permission for a later close attempt.
|
||||
if (expired()) hasDirty = true;
|
||||
}
|
||||
if (!hasDirty) {
|
||||
reason = undefined;
|
||||
tasksToResume = [];
|
||||
}
|
||||
} else {
|
||||
// All prior work was finished/cancelled; invalidate its late pause.
|
||||
transferQuitPendingRef.current = null;
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason, response);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
// Report refusal before recovery, which may itself take a long time.
|
||||
// A resume promise can span the entire transfer, so do not hold the
|
||||
// close gate until it settles. A later close must be able to pause again.
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
if (transferQuitPendingRef.current === attempt) transferQuitPendingRef.current = null;
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -384,22 +384,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
@@ -582,7 +566,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.refreshModelList': 'Refresh model list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -966,51 +966,6 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Settings > Shortcuts -> key bindings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Next Tab',
|
||||
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
|
||||
'settings.shortcuts.binding.close-tab': 'Close Tab',
|
||||
'settings.shortcuts.binding.close-session': 'Close Session Pane',
|
||||
'settings.shortcuts.binding.new-tab': 'New Local Tab',
|
||||
'settings.shortcuts.binding.copy': 'Copy from Terminal',
|
||||
'settings.shortcuts.binding.paste': 'Paste to Terminal',
|
||||
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
|
||||
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
|
||||
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
|
||||
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
|
||||
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
|
||||
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
|
||||
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
|
||||
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
|
||||
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
|
||||
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
|
||||
'settings.shortcuts.binding.new-workspace': 'New Workspace',
|
||||
'settings.shortcuts.binding.snippets': 'Open Snippets',
|
||||
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.binding.open-settings': 'Open Settings',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Rename File',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
|
||||
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
'action.newSubfolder': 'New Subfolder',
|
||||
@@ -1225,7 +1180,6 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
@@ -1235,77 +1189,8 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
"settings.plugins.manager.enabled": "Enabled",
|
||||
"settings.plugins.manager.disabled": "Disabled",
|
||||
"settings.plugins.manager.enable": "Enable",
|
||||
"settings.plugins.manager.disable": "Disable",
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.interaction.title": "Plugin request",
|
||||
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
|
||||
"plugins.interaction.input": "Input text",
|
||||
"plugins.interaction.filter": "Filter choices",
|
||||
"plugins.interaction.preview": "Preview clipboard",
|
||||
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
|
||||
"plugins.interaction.previewAction": "Preview clipboard",
|
||||
"plugins.interaction.share": "Share with plugin",
|
||||
"plugins.interaction.copy": "Copy to clipboard",
|
||||
"plugins.interaction.failed": "This request expired or could not be completed.",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.title": "Saved grants",
|
||||
"settings.plugins.permissions.refresh": "Refresh",
|
||||
"settings.plugins.permissions.loading": "Loading grants...",
|
||||
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
||||
"settings.plugins.permissions.always": "Until revoked",
|
||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||
"settings.plugins.permissions.session": "Until the session closes",
|
||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
|
||||
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
|
||||
"settings.plugins.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.manager.details": "View details",
|
||||
"settings.plugins.manager.noDescription": "No description provided.",
|
||||
"settings.plugins.market.installed": "Installed",
|
||||
"settings.plugins.market.sourceBadge": "Source example",
|
||||
"settings.plugins.market.title": "Marketplace",
|
||||
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
|
||||
"settings.plugins.market.back": "All plugins",
|
||||
"settings.plugins.market.install": "Install this version (disabled)",
|
||||
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
|
||||
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
|
||||
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
|
||||
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
|
||||
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
|
||||
"settings.plugins.market.retry": "Retry",
|
||||
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
|
||||
"settings.plugins.market.emptyReadme": "This README is empty.",
|
||||
"settings.plugins.market.search": "Search name, author or description",
|
||||
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
|
||||
"settings.plugins.market.manifest": "Compatibility and permissions",
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
|
||||
"settings.plugins.openView": "Open",
|
||||
"settings.plugins.openManager": "Open plugin manager",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.title': 'Plugin settings',
|
||||
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const enMonitorMessages = {
|
||||
"monitor.diskIo": "Disk I/O",
|
||||
"monitor.diskRead": "Read",
|
||||
"monitor.diskWrite": "Write",
|
||||
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
|
||||
|
||||
"monitor.actions": "Panel actions",
|
||||
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
|
||||
"monitor.username": "Username",
|
||||
|
||||
@@ -26,11 +26,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -137,7 +132,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -199,36 +194,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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)
|
||||
@@ -77,8 +68,6 @@ 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',
|
||||
@@ -879,7 +868,6 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
|
||||
@@ -370,7 +370,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Open target folder',
|
||||
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
|
||||
'sftp.transfers.copyTargetPath': 'Copy target path',
|
||||
'sftp.transfers.cleanupWarnings': 'Backup cleanup warnings: {count}. Show files for details.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
|
||||
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
|
||||
'sftp.transfers.pathConflict.title': 'Transfer blocked',
|
||||
|
||||
@@ -90,15 +90,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
'ai.providers.headers.value': 'Valor de la cabecera',
|
||||
'ai.providers.headers.add': 'Agregar cabecera',
|
||||
'ai.providers.headers.remove': 'Quitar cabecera',
|
||||
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
|
||||
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
|
||||
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -289,14 +280,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
|
||||
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -384,22 +367,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
@@ -582,7 +549,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
@@ -711,9 +677,6 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
|
||||
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
|
||||
@@ -84,32 +84,6 @@ export const esCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
'credentials.protectionUnavailable.action': 'Abrir configuración',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty está bloqueado',
|
||||
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
|
||||
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
|
||||
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
|
||||
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
|
||||
'appLock.passwordLabel': 'Contraseña de bloqueo',
|
||||
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
|
||||
'appLock.unlock': 'Desbloquear',
|
||||
'appLock.unlocking': 'Desbloqueando...',
|
||||
'appLock.usePassword': 'Usar contraseña',
|
||||
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
|
||||
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
|
||||
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparando {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
|
||||
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
|
||||
'appLock.reset.cancel': 'Cancelar',
|
||||
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.resetting': 'Restableciendo...',
|
||||
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
|
||||
'topTabs.lockApp': 'Bloquear Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Configuración',
|
||||
'settings.tab.application': 'Aplicación',
|
||||
@@ -145,65 +119,6 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
|
||||
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Bloqueo de la aplicación',
|
||||
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
|
||||
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
|
||||
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
|
||||
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
|
||||
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
|
||||
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disabling': 'Desactivando...',
|
||||
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
|
||||
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
|
||||
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
|
||||
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
|
||||
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
|
||||
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
|
||||
'settings.appLock.timeout': 'Tiempo de inactividad',
|
||||
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
|
||||
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
|
||||
'settings.appLock.timeout.1': '1 minuto',
|
||||
'settings.appLock.timeout.5': '5 minutos',
|
||||
'settings.appLock.timeout.15': '15 minutos',
|
||||
'settings.appLock.timeout.30': '30 minutos',
|
||||
'settings.appLock.timeout.60': '60 minutos',
|
||||
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
|
||||
'settings.appLock.currentPassword': 'Contraseña actual',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
|
||||
'settings.appLock.newPassword': 'Nueva contraseña',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
|
||||
'settings.appLock.confirmPassword': 'Confirmar contraseña',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
|
||||
'settings.appLock.savePassword': 'Definir y activar',
|
||||
'settings.appLock.savingPassword': 'Guardando...',
|
||||
'settings.appLock.passwordSet': 'Contraseña configurada',
|
||||
'settings.appLock.replacePassword': 'Reemplazar contraseña',
|
||||
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
|
||||
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
|
||||
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
|
||||
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
|
||||
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
|
||||
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
|
||||
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Proxy de red',
|
||||
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
|
||||
@@ -864,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -998,7 +913,6 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
|
||||
@@ -1225,7 +1139,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
@@ -1235,77 +1148,8 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
|
||||
"settings.plugins.manager.enabled": "Activado",
|
||||
"settings.plugins.manager.disabled": "Desactivado",
|
||||
"settings.plugins.manager.enable": "Activar",
|
||||
"settings.plugins.manager.disable": "Desactivar",
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.interaction.title": "Solicitud de complemento",
|
||||
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
|
||||
"plugins.interaction.input": "Introducir texto",
|
||||
"plugins.interaction.filter": "Filtrar opciones",
|
||||
"plugins.interaction.preview": "Vista previa del portapapeles",
|
||||
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
|
||||
"plugins.interaction.previewAction": "Vista previa del portapapeles",
|
||||
"plugins.interaction.share": "Compartir con el complemento",
|
||||
"plugins.interaction.copy": "Copiar al portapapeles",
|
||||
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.title": "Autorizaciones guardadas",
|
||||
"settings.plugins.permissions.refresh": "Actualizar",
|
||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
||||
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
|
||||
"settings.plugins.permissions.always": "Hasta su revocación",
|
||||
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
|
||||
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
|
||||
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
|
||||
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
|
||||
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
|
||||
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
|
||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
|
||||
"settings.plugins.manager.details": "Ver detalles",
|
||||
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
|
||||
"settings.plugins.market.installed": "Instalado",
|
||||
"settings.plugins.market.sourceBadge": "Ejemplo de código",
|
||||
"settings.plugins.market.title": "Catálogo",
|
||||
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
|
||||
"settings.plugins.market.back": "Todos los plugins",
|
||||
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
|
||||
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
|
||||
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
|
||||
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
|
||||
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
|
||||
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
|
||||
"settings.plugins.market.retry": "Reintentar",
|
||||
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
|
||||
"settings.plugins.market.emptyReadme": "Este README está vacío.",
|
||||
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
|
||||
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
|
||||
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
|
||||
"settings.plugins.openView": "Abrir",
|
||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.title': 'Configuración de plugins',
|
||||
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const esMonitorMessages = {
|
||||
"monitor.diskIo": "E/S de disco",
|
||||
"monitor.diskRead": "Lectura",
|
||||
"monitor.diskWrite": "Escritura",
|
||||
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
|
||||
|
||||
"monitor.actions": "Acciones del panel",
|
||||
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
|
||||
"monitor.username": "Usuario",
|
||||
|
||||
@@ -26,16 +26,11 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
|
||||
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
|
||||
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
@@ -137,7 +132,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -199,36 +194,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
'systemManager.k8s.status.Running': 'En ejecución',
|
||||
'systemManager.k8s.status.Pending': 'Pendiente',
|
||||
'systemManager.k8s.status.Completed': 'Completado',
|
||||
'systemManager.k8s.status.Succeeded': 'Con éxito',
|
||||
'systemManager.k8s.status.Failed': 'Fallido',
|
||||
'systemManager.k8s.status.Unknown': 'Desconocido',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
|
||||
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
|
||||
'systemManager.k8s.status.Terminating': 'Terminando',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
|
||||
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Desalojado',
|
||||
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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)
|
||||
@@ -77,8 +68,6 @@ 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',
|
||||
@@ -219,7 +208,6 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.connected': 'Conectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
@@ -676,45 +664,6 @@ export const esTerminalMessages: Messages = {
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
'keychain.generate.standardKeys': 'Claves estándar',
|
||||
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
|
||||
'keychain.generate.generating': 'Generando…',
|
||||
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
|
||||
'keychain.generate.resident': 'Guardar las credenciales en la llave',
|
||||
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
|
||||
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
|
||||
|
||||
// FIDO2 security key prompts and recovery states
|
||||
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
|
||||
'fido.prompt.details': 'Detalles de la solicitud',
|
||||
'fido.prompt.showPin': 'Mostrar PIN',
|
||||
'fido.prompt.hidePin': 'Ocultar PIN',
|
||||
'fido.prompt.verifying': 'Verificando…',
|
||||
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
|
||||
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
|
||||
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
|
||||
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Esperando tu llave…',
|
||||
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
|
||||
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
|
||||
'fido.prompt.touchDone': 'Ya la toqué',
|
||||
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
|
||||
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
|
||||
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
|
||||
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
|
||||
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
|
||||
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
|
||||
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
|
||||
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
|
||||
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
|
||||
'fido.recovery.retry': 'Reintentar verificación',
|
||||
'fido.recovery.usePassword': 'Usar la contraseña configurada',
|
||||
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
|
||||
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
@@ -881,7 +830,6 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
|
||||
@@ -370,7 +370,6 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
|
||||
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
|
||||
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
|
||||
'sftp.transfers.cleanupWarnings': 'Avisos de limpieza de copias de seguridad: {count}. Muestra los archivos para ver los detalles.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
|
||||
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
|
||||
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
|
||||
@@ -1044,18 +1043,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Probar conexión',
|
||||
'hostConnectionTest.test': 'Probar',
|
||||
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
|
||||
'hostConnectionTest.success': 'Conexión correcta',
|
||||
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
|
||||
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
|
||||
'hostConnectionTest.failed': 'No se pudo conectar.',
|
||||
'hostConnectionTest.retry': 'Reintentar',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
|
||||
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import es from "./es.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
// Locales that are not at full key parity with `en` yet. They are tracked here
|
||||
// so the remaining debt stays visible instead of silently rendering in English
|
||||
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
|
||||
// Move a locale out of this list once it covers every `en` key.
|
||||
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
|
||||
|
||||
const hasKey = (messages: Record<string, string>, key: string) =>
|
||||
Object.hasOwn(messages, key);
|
||||
|
||||
const interpolationTokens = (value: string) =>
|
||||
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
|
||||
.map((match) => match[0].replace(/[{}]/g, "").trim())
|
||||
.sort();
|
||||
|
||||
test("es defines every message key that en defines", () => {
|
||||
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`es is missing ${missing.length} message keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es defines no message keys that en does not define", () => {
|
||||
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
|
||||
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
`es defines ${extra.length} keys that no longer exist in en`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es keeps the same interpolation placeholders as en", () => {
|
||||
const mismatched: string[] = [];
|
||||
|
||||
for (const key of Object.keys(es)) {
|
||||
if (!hasKey(en, key)) continue;
|
||||
|
||||
const expected = interpolationTokens(en[key] ?? "").join(",");
|
||||
const actual = interpolationTokens(es[key] ?? "").join(",");
|
||||
if (expected !== actual) mismatched.push(key);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
mismatched,
|
||||
[],
|
||||
`es changes interpolation placeholders for ${mismatched.length} keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every default shortcut has a label in en and es", () => {
|
||||
const locales = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = DEFAULT_KEY_BINDINGS.map(
|
||||
(binding) => `settings.shortcuts.binding.${binding.id}`,
|
||||
).filter((key) => !hasKey(locale.messages, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} shortcut labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
|
||||
// SettingsShortcutsTab falls back to `binding.label` only while the locale
|
||||
// resolves the key to itself. Once `en` defines the key, the locale becomes
|
||||
// the rendered source, so the two must not drift apart.
|
||||
for (const binding of DEFAULT_KEY_BINDINGS) {
|
||||
const key = `settings.shortcuts.binding.${binding.id}`;
|
||||
|
||||
assert.equal(
|
||||
en[key],
|
||||
binding.label,
|
||||
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every locale outside the pending list is at full parity with en", () => {
|
||||
const locales = [
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(en).filter(
|
||||
(key) => !hasKey(locale.messages, key),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} message keys`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -323,22 +323,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
@@ -521,7 +505,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.refreshModelList': 'Обновить список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
@@ -938,7 +938,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
|
||||
@@ -1130,77 +1129,8 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
|
||||
"settings.plugins.manager.enabled": "Включён",
|
||||
"settings.plugins.manager.disabled": "Отключён",
|
||||
"settings.plugins.manager.enable": "Включить",
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.interaction.title": "Запрос плагина",
|
||||
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
|
||||
"plugins.interaction.input": "Ввести текст",
|
||||
"plugins.interaction.filter": "Фильтр вариантов",
|
||||
"plugins.interaction.preview": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
|
||||
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.share": "Передать плагину",
|
||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||
"settings.plugins.permissions.refresh": "Обновить",
|
||||
"settings.plugins.permissions.loading": "Загрузка разрешений...",
|
||||
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
|
||||
"settings.plugins.permissions.always": "До отзыва",
|
||||
"settings.plugins.permissions.application": "До выхода из Netcatty",
|
||||
"settings.plugins.permissions.session": "До закрытия сеанса",
|
||||
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
|
||||
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
|
||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
|
||||
"settings.plugins.manager.details": "Подробнее",
|
||||
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
|
||||
"settings.plugins.market.installed": "Установлен",
|
||||
"settings.plugins.market.sourceBadge": "Пример исходного кода",
|
||||
"settings.plugins.market.title": "Каталог",
|
||||
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
|
||||
"settings.plugins.market.back": "Все плагины",
|
||||
"settings.plugins.market.install": "Установить эту версию (отключённой)",
|
||||
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
|
||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
|
||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||
"settings.plugins.market.retry": "Повторить",
|
||||
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
|
||||
"settings.plugins.market.emptyReadme": "Этот README пуст.",
|
||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"settings.plugins.openView": "Открыть",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.title': 'Настройки плагинов',
|
||||
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
|
||||
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
|
||||
@@ -26,8 +26,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -134,7 +132,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -196,22 +194,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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)
|
||||
@@ -98,8 +89,6 @@ 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': 'Управление быстрыми сниппетами',
|
||||
@@ -886,7 +875,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': 'Управление потоком',
|
||||
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
|
||||
'serial.field.customPort': 'Путь к пользовательскому порту',
|
||||
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
|
||||
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
|
||||
'serial.type.hardware': 'Аппаратный',
|
||||
'serial.type.pseudo': 'Псевдотерминал',
|
||||
|
||||
@@ -131,7 +131,6 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -413,7 +412,6 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.openTargetFolder': 'Открыть целевую папку',
|
||||
'sftp.transfers.openTargetFolderError': 'Не удалось открыть целевую папку',
|
||||
'sftp.transfers.copyTargetPath': 'Копировать целевой путь',
|
||||
'sftp.transfers.cleanupWarnings': 'Предупреждения об удалении резервных копий: {count}. Откройте список файлов для подробностей.',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Целевой путь скопирован',
|
||||
'sftp.transfers.copyTargetPathError': 'Не удалось скопировать целевой путь',
|
||||
'sftp.transfers.pathConflict.title': 'Передача заблокирована',
|
||||
|
||||
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
const ALL_SETTINGS_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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";
|
||||
|
||||
test("folder cleanup-warning counts are localized in every supported locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, zhCN, zhTW })) {
|
||||
assert.match(messages["sftp.transfers.cleanupWarnings"] ?? "", /\{count\}/, locale);
|
||||
}
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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'], '工作階段日誌');
|
||||
});
|
||||
@@ -384,22 +384,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
@@ -582,7 +566,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.refreshModelList': '刷新模型列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
|
||||
@@ -785,7 +785,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
@@ -1035,7 +1034,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'sftp.transfers.openTargetFolder': '打开目标目录',
|
||||
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
|
||||
'sftp.transfers.copyTargetPath': '复制目标路径',
|
||||
'sftp.transfers.cleanupWarnings': '备份清理警告:{count}。展开文件列表查看详情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
|
||||
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
|
||||
'sftp.transfers.pathConflict.title': '无法开始传输',
|
||||
@@ -1150,77 +1148,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
"settings.plugins.manager.enabled": "已启用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "启用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.interaction.title": "插件请求",
|
||||
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
|
||||
"plugins.interaction.input": "输入文本",
|
||||
"plugins.interaction.filter": "筛选选项",
|
||||
"plugins.interaction.preview": "剪贴板预览",
|
||||
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
|
||||
"plugins.interaction.previewAction": "预览剪贴板",
|
||||
"plugins.interaction.share": "分享给插件",
|
||||
"plugins.interaction.copy": "复制到剪贴板",
|
||||
"plugins.interaction.failed": "此请求已过期或无法完成。",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"settings.plugins.permissions.title": "已保存的授权",
|
||||
"settings.plugins.permissions.refresh": "刷新",
|
||||
"settings.plugins.permissions.loading": "正在读取授权...",
|
||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||
"settings.plugins.permissions.always": "直到撤销",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到会话关闭",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
|
||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
|
||||
"settings.plugins.manager.details": "查看详情",
|
||||
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
|
||||
"settings.plugins.market.installed": "已安装",
|
||||
"settings.plugins.market.sourceBadge": "源码示例",
|
||||
"settings.plugins.market.title": "插件市场",
|
||||
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安装此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
|
||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||
"settings.plugins.market.retry": "重试",
|
||||
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 内容为空。",
|
||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||
"settings.plugins.market.manifest": "兼容性与权限",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.title': '插件设置',
|
||||
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
|
||||
"monitor.username": "用户名",
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -137,7 +132,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -199,36 +194,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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)
|
||||
@@ -496,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
@@ -621,7 +612,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
@@ -969,7 +959,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
|
||||
@@ -524,8 +524,6 @@ 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': '管理快捷代码片段',
|
||||
|
||||
@@ -374,22 +374,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
@@ -572,7 +556,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.refreshModelList': '重新整理模型清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
|
||||
@@ -787,7 +787,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
@@ -1037,7 +1036,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'sftp.transfers.openTargetFolder': '開啟目標目錄',
|
||||
'sftp.transfers.openTargetFolderError': '無法開啟目標目錄',
|
||||
'sftp.transfers.copyTargetPath': '複製目標路徑',
|
||||
'sftp.transfers.cleanupWarnings': '備份清理警告:{count}。展開檔案清單查看詳情。',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已複製目標路徑',
|
||||
'sftp.transfers.copyTargetPathError': '無法複製目標路徑',
|
||||
'sftp.transfers.pathConflict.title': '無法開始傳輸',
|
||||
@@ -1153,78 +1151,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
|
||||
"settings.plugins.manager.enabled": "已啟用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "啟用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.interaction.title": "外掛請求",
|
||||
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
|
||||
"plugins.interaction.input": "輸入文字",
|
||||
"plugins.interaction.filter": "篩選選項",
|
||||
"plugins.interaction.preview": "剪貼簿預覽",
|
||||
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
|
||||
"plugins.interaction.previewAction": "預覽剪貼簿",
|
||||
"plugins.interaction.share": "分享給外掛",
|
||||
"plugins.interaction.copy": "複製到剪貼簿",
|
||||
"plugins.interaction.failed": "此請求已過期或無法完成。",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"settings.plugins.permissions.title": "已儲存的授權",
|
||||
"settings.plugins.permissions.refresh": "重新整理",
|
||||
"settings.plugins.permissions.loading": "正在讀取授權...",
|
||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||
"settings.plugins.permissions.always": "直到撤銷",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
|
||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.title': '外掛程式設定',
|
||||
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
|
||||
"settings.plugins.manager.details": "查看詳情",
|
||||
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
|
||||
"settings.plugins.market.installed": "已安裝",
|
||||
"settings.plugins.market.sourceBadge": "原始碼範例",
|
||||
"settings.plugins.market.title": "插件市場",
|
||||
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安裝此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
|
||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||
"settings.plugins.market.retry": "重試",
|
||||
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 內容為空。",
|
||||
"settings.plugins.market.search": "搜索名稱、作者或描述",
|
||||
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
|
||||
"settings.plugins.market.manifest": "兼容性與權限",
|
||||
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
|
||||
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
|
||||
'settings.plugins.pressKeybinding': '請按下組合鍵',
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
|
||||
|
||||
"monitor.actions": "面板操作",
|
||||
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
|
||||
"monitor.username": "使用者名稱",
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -137,7 +132,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -199,36 +194,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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)
|
||||
@@ -496,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
@@ -621,7 +612,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
|
||||
@@ -969,7 +959,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.flowControl': '流量控制',
|
||||
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
|
||||
'serial.field.customPort': '自訂序列埠路徑',
|
||||
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬體',
|
||||
'serial.type.pseudo': '虛擬終端',
|
||||
|
||||
@@ -524,8 +524,6 @@ 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': '管理快捷程式碼片段',
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -72,92 +61,6 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -717,103 +620,3 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(freezeHistory);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
count - index,
|
||||
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
|
||||
id: `message-${index}-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'ordinary text '.repeat(8),
|
||||
timestamp: messageIndex,
|
||||
...(messageIndex % 2 ? {
|
||||
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: 'reasoning',
|
||||
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
|
||||
}],
|
||||
},
|
||||
} : {}),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
|
||||
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
|
||||
const expected = sessions.slice(0, 50);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
|
||||
const result = serializeSessionsForStorage(sessions);
|
||||
|
||||
assert.equal(stringify.mock.callCount(), expected.length);
|
||||
assert.equal(result.json, expectedJson);
|
||||
assert.deepEqual(result.sessions, expected);
|
||||
assert.equal(result.sessions[0], sessions[0]);
|
||||
});
|
||||
}
|
||||
|
||||
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = makePersistenceHistory(1);
|
||||
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
|
||||
freezeHistory(sessions);
|
||||
const fullJson = JSON.stringify(sessions);
|
||||
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
|
||||
for (const budget of [fullJson.length, fullJson.length + 1]) {
|
||||
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
|
||||
}
|
||||
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
|
||||
assert.ok(overflow.json.length < fullJson.length);
|
||||
assert.equal(JSON.stringify(sessions), fullJson);
|
||||
});
|
||||
|
||||
test('fit-first empty history preserves output even below the minimum array budget', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
for (const budget of [0, 1, 2, 3]) {
|
||||
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(20));
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
const result = serializeSessionsForStorage(sessions, budget);
|
||||
// Same work as the original ladder: no second full-session serialization
|
||||
// after the fit check, even when a last-resort degraded chat is needed.
|
||||
assert.equal(stringify.mock.callCount(), expectedCalls);
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
@@ -363,30 +363,12 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
@@ -397,6 +379,7 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { editorTabStore } from "./editorTabStore.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab, subscribeEditorWindowDirtyCheck } from "./editorWindowClient.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
@@ -145,16 +145,3 @@ test("a closing destination rejects a popout without releasing source content",
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
|
||||
|
||||
test("detached editor dirty-check replies echo the main-process request", (t) => {
|
||||
let query!: (request: { requestId: string; deadline: number }) => void;
|
||||
const replies: unknown[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (...args: unknown[]) => replies.push(args),
|
||||
}));
|
||||
t.after(subscribeEditorWindowDirtyCheck(() => true));
|
||||
query({ requestId: "detached-check", deadline: Date.now() + 5000 });
|
||||
assert.deepEqual(replies, [[true, undefined, { requestId: "detached-check" }]]);
|
||||
});
|
||||
@@ -200,7 +200,7 @@ export function reportEditorWindowCloseTabsResult(
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors((request) => {
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
@@ -208,7 +208,7 @@ export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, undefined, { requestId: request?.requestId });
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
|
||||
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "plain"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
return sessionId === "plain";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["images", "plain"]);
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
|
||||
const blocked: string[] = [];
|
||||
const blockedOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "also-images"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => blockedOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
blocked.push(sessionId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(blocked, ["images", "also-images"]);
|
||||
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["victim"]);
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
|
||||
const recovered: string[] = [];
|
||||
const recoveredOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim", "next"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => recoveredOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
recovered.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return sessionId === "next";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered, ["victim", "next"]);
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
|
||||
let keepCount = 0;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 2;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 0;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
try {
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 1;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
|
||||
} finally {
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => false,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => true,
|
||||
});
|
||||
assert.deepEqual(outcomes, []);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
});
|
||||
|
||||
const outcomes: string[] = [];
|
||||
const pending = retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("leaving");
|
||||
outcomes.push("soft-hide");
|
||||
},
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId: "second",
|
||||
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
await pending;
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
|
||||
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
});
|
||||
});
|
||||
|
||||
const retained: string[] = [];
|
||||
const hibernated: string[] = [];
|
||||
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
||||
retained.push(sessionId);
|
||||
},
|
||||
fullHibernate: () => hibernated.push(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(hibernated.length, 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
/**
|
||||
* Walk keep-count victims until one hibernate completes, then retain the
|
||||
* leaving tab only if its reserved slot still fits.
|
||||
*
|
||||
* The reservation is taken before the first await. Clearing a victim marker
|
||||
* during wake would otherwise look like spare capacity to a second hidden-tab
|
||||
* timer, and both tabs would soft-hide once the same eviction resolved.
|
||||
*/
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
|
||||
keepCount: number;
|
||||
/**
|
||||
* Live keep-count read after eviction settles. Settings can change while the
|
||||
* victim drains or serializes; retaining against the captured count would
|
||||
* soft-hide a tab the user no longer wants kept.
|
||||
*/
|
||||
resolveKeepCount?: () => number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
evict?: (sessionId: string) => Promise<boolean> | boolean;
|
||||
}): Promise<void> {
|
||||
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
|
||||
try {
|
||||
const evict = input.evict ?? ((sessionId: string) => (
|
||||
terminalHiddenRendererStore.evictSoftHidden(sessionId)
|
||||
));
|
||||
let completed = false;
|
||||
for (const candidate of input.candidates) {
|
||||
if (!candidate || candidate === input.sessionId) continue;
|
||||
if (await evict(candidate)) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
}
|
||||
input.fullHibernate();
|
||||
} finally {
|
||||
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,6 @@ test('session actions register and unregister', () => {
|
||||
createWorkspaceFromSessions: noop,
|
||||
addSessionToWorkspace: noop,
|
||||
appendHostToWorkspace: noop,
|
||||
appendHostToWorkspaceConfirmed: noop,
|
||||
appendLocalTerminalToWorkspace: noop,
|
||||
createWorkspaceFromTargets: noop,
|
||||
updateSplitSizes: noop,
|
||||
@@ -103,10 +102,6 @@ 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,
|
||||
|
||||
@@ -57,7 +57,6 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
| 'createWorkspaceFromTargets'
|
||||
| 'updateSplitSizes'
|
||||
@@ -69,12 +68,8 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleComposeSendTarget'
|
||||
| 'isComposeSendToAll'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'toggleGlobalComposeSendTarget'
|
||||
| 'isGlobalComposeSendToAll'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -180,11 +179,9 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -743,7 +740,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: unwrapSftpIpcError(error),
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
|
||||
|
||||
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
|
||||
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
|
||||
for (const error of [
|
||||
"read ECONNABORTED",
|
||||
"socket hang up",
|
||||
"ERR_SFTP_CHANNEL_CLOSED",
|
||||
"SFTP pipelined upload failed: socket hang up",
|
||||
"client-socket",
|
||||
]) {
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
|
||||
}
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
|
||||
assert.equal(canRetryFromCheckpoint(failed()), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(undefined), false);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
|
||||
// Failures that only mean "the SSH/SFTP connection went away". The staged
|
||||
// .part is intact up to the checkpoint, so Retry can continue from it instead
|
||||
// of restarting at byte 0 (which also strands the old stage on the server,
|
||||
// because the new attempt stages under a fresh transfer id).
|
||||
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
|
||||
// cover every code or message isSftpChannelLossError already treats as channel
|
||||
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
|
||||
// client-socket). A miss here restarts at byte 0 and strands the .part.
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
|
||||
|
||||
/**
|
||||
* True when Retry should resume the same task from its checkpoint.
|
||||
* Only local-to-remote uploads (`direction === "upload"`) do this. Downloads
|
||||
* and remote copies have no unfingerprinted stage to keep, so they stay on
|
||||
* the owner's fresh retry.
|
||||
*/
|
||||
export function canRetryFromCheckpoint(task: TransferTask | undefined): boolean {
|
||||
if (!task) return false;
|
||||
return task.status === "failed"
|
||||
&& !task.isDirectory
|
||||
&& !task.parentTaskId
|
||||
&& task.retryable !== false
|
||||
&& !task.conflict
|
||||
&& task.direction === "upload"
|
||||
&& (task.checkpointBytes ?? 0) > 0
|
||||
&& SESSION_LOSS_ERROR.test(task.error ?? "");
|
||||
}
|
||||
@@ -334,111 +334,6 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("a file batch that rejects mid-walk does not emit unhandledRejection", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const root = {
|
||||
...rootTask(),
|
||||
targetPath: "C:\\Users\\alice\\Downloads\\folder",
|
||||
};
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
};
|
||||
let releaseLater!: () => void;
|
||||
const laterGate = new Promise<void>((resolve) => {
|
||||
releaseLater = resolve;
|
||||
});
|
||||
let laterListingStarted!: () => void;
|
||||
const laterListing = new Promise<void>((resolve) => {
|
||||
laterListingStarted = resolve;
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "owner",
|
||||
cancelledTasksRef: { current: new Set() },
|
||||
pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers,
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async (_sftpId: string, path: string) => {
|
||||
if (path === "/source") return [directoryEntry("bad"), directoryEntry("later")];
|
||||
if (path === "/source/bad") return [fileEntry("..\\outside.txt")];
|
||||
laterListingStarted();
|
||||
await laterGate;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
const rejectionText = (reason: unknown) => (
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
let running: Promise<number> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(
|
||||
root,
|
||||
"source-sftp",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
"auto",
|
||||
"auto",
|
||||
root.id,
|
||||
);
|
||||
await laterListing;
|
||||
// The bad directory's batch is already queued. End this turn while the
|
||||
// sibling listing is still blocked so an unhandled rejection can surface.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
releaseLater();
|
||||
await assert.rejects(running, /unsafe transfer path/i);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
} finally {
|
||||
releaseLater();
|
||||
await running?.catch(() => {});
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
|
||||
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -79,30 +78,11 @@ function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING
|
||||
};
|
||||
}
|
||||
|
||||
/** Observed at queue time so a mid-walk rejection is not unhandled. */
|
||||
type QueuedFileBatch =
|
||||
| { ok: true; errors: number }
|
||||
| { ok: false; error: unknown };
|
||||
|
||||
type DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: number;
|
||||
nextEntryIndex: number;
|
||||
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
|
||||
listingGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Tree-wide file-transfer gate (transfer concurrency setting). Each directory
|
||||
* hands its files to workers that share this gate, and the walk does not wait
|
||||
* for a directory's files before moving on, so there is no per-directory
|
||||
* barrier: trees of many small directories keep N files in flight.
|
||||
*/
|
||||
fileGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Per-directory file batches. Each promise is settled when queued; the root
|
||||
* walk inspects the outcomes after discovery.
|
||||
*/
|
||||
pendingFileBatches?: Promise<QueuedFileBatch>[];
|
||||
/** First session loss seen by any batch; later batches stop claiming files. */
|
||||
sessionLostError?: Error | null;
|
||||
};
|
||||
|
||||
function isCancelledLocalOrGlobal(
|
||||
@@ -547,7 +527,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const walkDirectory = async (
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
@@ -561,8 +541,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
rootProgress?: DirectoryDiscoveryProgress,
|
||||
): Promise<number> => {
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -573,12 +552,11 @@ export function useSftpDirectoryTransferOps({
|
||||
// as soon as each directory is listed; compressed upload is a separate path.
|
||||
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
|
||||
let totalErrors = 0;
|
||||
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
@@ -706,7 +684,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await walkDirectory(
|
||||
totalErrors += await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -743,21 +721,11 @@ export function useSftpDirectoryTransferOps({
|
||||
let sessionLostError: Error | null = null;
|
||||
const directoryEntryBase = progress.nextEntryIndex;
|
||||
progress.nextEntryIndex += regularFiles.length;
|
||||
const directoryFiles = regularFiles;
|
||||
if (!progress.fileGate) {
|
||||
// Same setting runSftpTransferWorkers reads, created on first file.
|
||||
progress.fileGate = createDirectoryListingGate(resolveSftpTransferConcurrency(
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
));
|
||||
}
|
||||
const fileGate = progress.fileGate;
|
||||
|
||||
const fileBatch = (async (): Promise<number> => {
|
||||
await runSftpTransferWorkers(
|
||||
directoryFiles,
|
||||
regularFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file, fileIndex) => fileGate.run(async () => {
|
||||
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
|
||||
async (file, fileIndex) => {
|
||||
if (sessionLostError) throw sessionLostError;
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -1025,7 +993,6 @@ export function useSftpDirectoryTransferOps({
|
||||
);
|
||||
if (isSessionError(err) && !sessionLostError) {
|
||||
sessionLostError = err instanceof Error ? err : new Error(message);
|
||||
progress.sessionLostError = progress.sessionLostError ?? sessionLostError;
|
||||
// Fail remaining queued siblings quickly with a clear cause.
|
||||
setTransfers((prev) => prev.map((t) => (
|
||||
t.parentTaskId === rootTaskId
|
||||
@@ -1048,10 +1015,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
beforeClaim: async () => {
|
||||
if (sessionLostError || progress.sessionLostError) return;
|
||||
if (sessionLostError) return;
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
},
|
||||
},
|
||||
@@ -1063,69 +1030,17 @@ export function useSftpDirectoryTransferOps({
|
||||
throw err;
|
||||
});
|
||||
|
||||
totalErrors += errors.length;
|
||||
if (sessionLostError) {
|
||||
logger.warn("[SFTP] Directory transfer stopped early: session lost", sessionLostError.message);
|
||||
} else if (errors.length > 0) {
|
||||
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
|
||||
}
|
||||
return errors.length;
|
||||
})();
|
||||
// Do not wait here: the walk continues into sibling directories while
|
||||
// this directory's files drain through the shared fileGate. Settle the
|
||||
// batch now so a failure during that walk (unsafe filename, unexpected
|
||||
// worker error) is handled. The root drain still surfaces the error.
|
||||
progress.pendingFileBatches.push(fileBatch.then(
|
||||
(errors): QueuedFileBatch => ({ ok: true, errors }),
|
||||
(error: unknown): QueuedFileBatch => ({ ok: false, error }),
|
||||
));
|
||||
}
|
||||
|
||||
return totalErrors;
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
...args: Parameters<typeof walkDirectory>
|
||||
): Promise<number> => {
|
||||
const [, , , , , , , , , , , discoveryProgress, traversalBudget] = args;
|
||||
if (discoveryProgress) return walkDirectory(...args);
|
||||
// Root of a folder task: own the tree-wide progress so we can drain every
|
||||
// directory's file batch, even when the walk itself throws, before the
|
||||
// caller decides the folder's final state.
|
||||
const rootProgress: DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
pendingFileBatches: [],
|
||||
};
|
||||
let walkErrors = 0;
|
||||
let walkError: unknown = null;
|
||||
try {
|
||||
const [task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks] = args;
|
||||
walkErrors = await walkDirectory(
|
||||
task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks,
|
||||
undefined, traversalBudget, rootProgress,
|
||||
);
|
||||
} catch (error) {
|
||||
walkError = error;
|
||||
}
|
||||
let fileErrors = 0;
|
||||
let batchError: unknown = null;
|
||||
const batches = rootProgress.pendingFileBatches!;
|
||||
for (let index = 0; index < batches.length; index += 1) {
|
||||
const outcome = await batches[index];
|
||||
if (outcome.ok) {
|
||||
fileErrors += outcome.errors;
|
||||
} else {
|
||||
batchError = batchError ?? outcome.error;
|
||||
}
|
||||
}
|
||||
if (walkError) throw walkError;
|
||||
if (batchError) throw batchError;
|
||||
return walkErrors + fileErrors;
|
||||
};
|
||||
|
||||
|
||||
return { transferFile, transferDirectory };
|
||||
}
|
||||
@@ -163,9 +163,6 @@ export async function restoreSftpTransferHistoryCooperatively(
|
||||
const child = parentChildren[childIndex];
|
||||
if (
|
||||
child.status === "completed"
|
||||
// Match canonical pruning: warning rows carry recovery paths that
|
||||
// cannot be represented by the directory's compact manifest.
|
||||
&& !child.warning
|
||||
&& Number.isSafeInteger(child.directoryEntryIndex)
|
||||
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
|
||||
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
|
||||
|
||||
@@ -38,20 +38,6 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,9 +6,6 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -135,34 +135,6 @@ test("completed dispatch consumes only exact identity and never restarts", async
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("successful invoke warnings reach the child before local completion can compact it", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "warning-result-child", parentTaskId: "warning-result-root" };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-a manually.";
|
||||
store.upsertTasks([root, task]);
|
||||
t.after(() => store.dismiss(root.id));
|
||||
const result = await runTransferAndWaitForOwner(task, async () => ({ warning }), () => false);
|
||||
assert.equal(result?.warning, warning);
|
||||
assert.equal(store.getTask(task.id)?.warning, warning, "capture metadata before the caller finishes the child");
|
||||
store.patchTask(task.id, { status: "completed", transferredBytes: 1 });
|
||||
assert.equal(store.getTask(task.id)?.status, "completed", "warning-bearing children stay available for inspection");
|
||||
assert.equal(store.getTask(task.id)?.warning, warning);
|
||||
});
|
||||
|
||||
for (const outcome of [{ error: "write failed" }, { cancelled: true }]) {
|
||||
test(`unsuccessful invoke results do not add cleanup warnings (${JSON.stringify(outcome)})`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `unsuccessful-warning-${"error" in outcome ? "failed" : "cancelled"}`, parentTaskId: undefined };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
await runTransferAndWaitForOwner(task, async () => ({ ...outcome, warning: "not a committed upload" }), () => false);
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface FileWatchSyncedEvent {
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
bytesWritten: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface FileWatchErrorEvent {
|
||||
|
||||
@@ -156,10 +156,6 @@ export const createUploadTaskCallbacks = ({
|
||||
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
|
||||
});
|
||||
},
|
||||
onTaskWarning: (taskId: string, warning: string) => {
|
||||
// Preserve cleanup details before completion can compact a child row.
|
||||
store.patchTask(taskId, { warning });
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
store.patchTask(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -958,7 +957,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1264,14 +1263,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
/** @test lifecycle repaints coalesce to one frame and still settle the final status */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { latchTransferPauseTree, releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
|
||||
type Frame = { id: number; callback: (time: number) => void; cancelled: boolean };
|
||||
|
||||
function installFrameQueue() {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const frames: Frame[] = [];
|
||||
let nextId = 1;
|
||||
const requestAnimationFrame = (callback: (time: number) => void) => {
|
||||
const frame: Frame = { id: nextId++, callback, cancelled: false };
|
||||
frames.push(frame);
|
||||
return frame.id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => {
|
||||
const frame = frames.find((candidate) => candidate.id === id);
|
||||
if (frame) frame.cancelled = true;
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {},
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
flush: async () => {
|
||||
const pending = frames.splice(0, frames.length).filter((frame) => !frame.cancelled);
|
||||
await act(async () => {
|
||||
for (const frame of pending) frame.callback(0);
|
||||
});
|
||||
return pending.length;
|
||||
},
|
||||
restore: () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(id: string, status: TransferTask["status"], error?: string): TransferTask {
|
||||
return {
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
error,
|
||||
totalBytes: 10,
|
||||
transferredBytes: status === "completed" ? 10 : 2,
|
||||
speed: status === "transferring" ? 1 : 0,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(ownerId: string) {
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
get ops() {
|
||||
return ops;
|
||||
},
|
||||
render: async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
},
|
||||
unmount: async () => {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
renderer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("lifecycle frames keep the final completion, pause, cancel, and error status", async () => {
|
||||
const ownerId = "lifecycle-flush-final-status";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-row";
|
||||
try {
|
||||
await view.render();
|
||||
const stages: Array<{ status: TransferTask["status"]; error?: string }> = [
|
||||
{ status: "transferring" },
|
||||
{ status: "paused" },
|
||||
{ status: "cancelled" },
|
||||
{ status: "failed", error: "connection reset" },
|
||||
{ status: "completed" },
|
||||
];
|
||||
for (const stage of stages) {
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, stage.status, stage.error)]);
|
||||
});
|
||||
const paintsNow = stage.status !== "transferring";
|
||||
if (paintsNow) {
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} else {
|
||||
assert.notEqual(view.ops?.transfers.find((row) => row.id === taskId)?.status, stage.status);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
assert.equal(await queue.flush(), 1);
|
||||
}
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, stage.status);
|
||||
assert.equal(painted?.error, stage.error);
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId).find((row) => row.id === taskId)?.status,
|
||||
stage.status,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("parent pause paints while a child frame is pending and a stale publish cannot reopen it", async () => {
|
||||
const ownerId = "lifecycle-flush-parent-pause";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const parentId = "folder-parent";
|
||||
const childId = "folder-child";
|
||||
const parent = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(parentId, status),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
const child = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(childId, status),
|
||||
parentTaskId: parentId,
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("transferring")]);
|
||||
assert.equal(view.ops?.transfers.length, 0, "child lifecycle churn stays on the pending frame");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
// Pause lands while that frame is still queued. Latch afterwards: the
|
||||
// control-epoch guard drops a panel publish that arrives already latched,
|
||||
// and Resume is the equal-epoch republish that happens only after unlatch.
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("paused"), child("paused")]);
|
||||
});
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === parentId)?.status, "paused");
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === childId)?.status, "paused");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0, "pause cancels the pre-pause frame");
|
||||
|
||||
latchTransferPauseTree(parentId, [childId]);
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("paused")]);
|
||||
});
|
||||
await queue.flush();
|
||||
assert.equal(sftpTransferCenterStore.getTask(parentId)?.status, "paused");
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "paused");
|
||||
const paintedParent = view.ops?.transfers.find((row) => row.id === parentId);
|
||||
assert.equal(paintedParent?.status, "paused");
|
||||
assert.equal(
|
||||
view.ops?.transfers.filter((row) => (
|
||||
!row.parentTaskId && ["pending", "queued", "transferring", "pausing"].includes(row.status)
|
||||
)).length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
releaseTransferPauseTree(parentId, [childId]);
|
||||
sftpTransferCenterStore.dismiss(parentId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("unmount cancels the pending lifecycle frame and remount reads the settled store row", async () => {
|
||||
const ownerId = "lifecycle-flush-unmount";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-unmount-row";
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, "failed", "write failed")]);
|
||||
assert.equal(view.ops?.transfers.length, 0);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
await view.unmount();
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
assert.equal(await queue.flush(), 0);
|
||||
|
||||
await view.render();
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, "failed");
|
||||
assert.equal(painted?.error, "write failed");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
@@ -56,166 +56,6 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
@@ -440,9 +280,6 @@ test("stat-less remote source keeps the listing size in the overwrite dialog and
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
// Lifecycle paints coalesce to one frame. The plan lives on the ref
|
||||
// immediately; React state catches up on the queued animation frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
resolveDirectDirectoryDownloadFinalStatus,
|
||||
} from "./downloadTransferTask";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -159,89 +158,6 @@ export function finishTransferTask(
|
||||
return status;
|
||||
}
|
||||
|
||||
const STICKY_CONTROL_STATUSES = new Set<TransferStatus>([
|
||||
"paused",
|
||||
"pausing",
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
"interrupted",
|
||||
"attention",
|
||||
]);
|
||||
|
||||
const LIVE_QUEUE_STATUSES = new Set<TransferStatus>([
|
||||
"transferring",
|
||||
"pending",
|
||||
"queued",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pause, resume, and cancel paint on the same turn. Per-file complete/queue
|
||||
* churn stays on the animation frame so a folder upload does not commit once
|
||||
* per child.
|
||||
*/
|
||||
export function shouldPaintTransferControlNow(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
): boolean {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
return next.some((row) => {
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (prevRow?.status === row.status) return false;
|
||||
if (row.status === "paused" || row.status === "pausing" || row.status === "cancelled") return true;
|
||||
if (prevRow && (prevRow.status === "paused" || prevRow.status === "pausing" || prevRow.status === "cancelled")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!row.parentTaskId
|
||||
&& prevRow
|
||||
&& (STICKY_CONTROL_STATUSES.has(row.status) || STICKY_CONTROL_STATUSES.has(prevRow.status))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Child lifecycle updates clone the ref they saw. If that clone still says the
|
||||
* folder is transferring after the store already paused it, publishing the
|
||||
* clone reopens the parent. An explicit status edit (resume, retry) is kept.
|
||||
*/
|
||||
export function reconcilePanelRowsWithStore(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
storeRows: readonly TransferTask[],
|
||||
): TransferTask[] {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
const storeById = new Map(storeRows.map((row) => [row.id, row]));
|
||||
let changed = false;
|
||||
const reconciled = next.map((row) => {
|
||||
const storeRow = storeById.get(row.id);
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (!storeRow || !STICKY_CONTROL_STATUSES.has(storeRow.status) || !LIVE_QUEUE_STATUSES.has(row.status)) {
|
||||
return row;
|
||||
}
|
||||
// A full-list write can disagree with the ref only because the ref already
|
||||
// moved to the store's pause. That is a stale snapshot, not Resume.
|
||||
// Resume releases the latch before it republishes transferring.
|
||||
const rootId = row.parentTaskId ?? row.id;
|
||||
const latchedReopen = isTransferOrRootPauseLatched(rootId, row.id);
|
||||
if (prevRow && prevRow.status !== row.status && !latchedReopen) return row;
|
||||
if (row.status === storeRow.status) return row;
|
||||
changed = true;
|
||||
return {
|
||||
...row,
|
||||
status: storeRow.status,
|
||||
speed: 0,
|
||||
error: storeRow.error,
|
||||
endTime: storeRow.endTime,
|
||||
pauseUnavailableReason: storeRow.pauseUnavailableReason,
|
||||
};
|
||||
});
|
||||
return changed ? reconciled : next as TransferTask[];
|
||||
}
|
||||
|
||||
/** User-facing path exclusivity notice (toast + logs). */
|
||||
function notifyPathConflict(
|
||||
existing: Pick<TransferTask, "fileName" | "status">,
|
||||
@@ -275,21 +191,6 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -321,85 +222,29 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
setTransfersState(transfersRef.current);
|
||||
}, [surfaceVisible]);
|
||||
|
||||
// Keep owner lifecycle changes in the global transfer store. Byte progress
|
||||
// arrives independently through the unified process-wide event stream.
|
||||
//
|
||||
// Do not pre-assign transfersRef before publishing the same array reference —
|
||||
// identity equality skips the store merge (avoids store↔listener loops).
|
||||
// Lifecycle writers (child add/start/complete) fire several times per file.
|
||||
// Repainting the whole SFTP view synchronously on each one saturates the
|
||||
// renderer on folders with many small files, which then throttles the
|
||||
// transfer itself. transfersRef/store stay authoritative; React state
|
||||
// catches up once per frame, except pause/resume/cancel and folder terminal
|
||||
// rows, which paint immediately.
|
||||
//
|
||||
// A frame queued for child churn must not commit a pre-pause ref after the
|
||||
// store has already paused the parent. The flush re-reads the store, and a
|
||||
// synchronous control paint bumps paintEpoch so that queued frame no-ops.
|
||||
const stateFlushHandleRef = useRef<{ cancel: () => void } | null>(null);
|
||||
const paintEpochRef = useRef(0);
|
||||
const cancelScheduledTransferPaint = useCallback(() => {
|
||||
stateFlushHandleRef.current?.cancel();
|
||||
stateFlushHandleRef.current = null;
|
||||
}, []);
|
||||
const paintTransfersNow = useCallback((rows: TransferTask[]) => {
|
||||
cancelScheduledTransferPaint();
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = rows;
|
||||
setTransfersState(rows);
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
const scheduleTransfersStateFlush = useCallback(() => {
|
||||
if (stateFlushHandleRef.current) return;
|
||||
const epochAtSchedule = paintEpochRef.current;
|
||||
const flush = () => {
|
||||
stateFlushHandleRef.current = null;
|
||||
// A pause/resume/cancel/terminal paint landed after this frame was queued.
|
||||
if (paintEpochRef.current !== epochAtSchedule) return;
|
||||
const storeRows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
const painted = reconcilePanelRowsWithStore(transfersRef.current, transfersRef.current, storeRows);
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = painted;
|
||||
setTransfersState(painted);
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const id = window.requestAnimationFrame(flush);
|
||||
stateFlushHandleRef.current = { cancel: () => window.cancelAnimationFrame(id) };
|
||||
} else {
|
||||
const id = setTimeout(flush, 16);
|
||||
stateFlushHandleRef.current = { cancel: () => clearTimeout(id) };
|
||||
}
|
||||
}, [ownerId]);
|
||||
useEffect(() => () => {
|
||||
cancelScheduledTransferPaint();
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden. Cancel any frame that
|
||||
// was queued against the hidden snapshot so it cannot paint over this catch-up.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
paintTransfersNow(transfersRef.current);
|
||||
}, [paintTransfersNow, surfaceVisible]);
|
||||
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
|
||||
const prev = transfersRef.current;
|
||||
const next = typeof update === "function"
|
||||
? (update as (value: TransferTask[]) => TransferTask[])(prev)
|
||||
: update;
|
||||
if (next === prev) return;
|
||||
const reconciled = reconcilePanelRowsWithStore(
|
||||
prev,
|
||||
next,
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId),
|
||||
);
|
||||
if (reconciled === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
|
||||
sftpTransferCenterStore.publishOwner(ownerId, next);
|
||||
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
if (shouldPaintTransferControlNow(prev, canonical)) {
|
||||
paintTransfersNow(canonical);
|
||||
return;
|
||||
}
|
||||
transfersRef.current = canonical;
|
||||
scheduleTransfersStateFlush();
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
setTransfersState(canonical);
|
||||
}, [ownerId]);
|
||||
|
||||
// Coalesce progress-only patches to one paint per animation frame. Without
|
||||
// this, each stream tick maps the full transfer list + publishOwner + store
|
||||
@@ -431,19 +276,9 @@ export const useSftpTransfers = ({
|
||||
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
|
||||
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
|
||||
// Keep ref hot for cancel/pause checks without waiting for paint.
|
||||
// A byte tick that still says "transferring" must not reopen a latched pause.
|
||||
transfersRef.current = transfersRef.current.map((row) => {
|
||||
if (row.id !== taskId) return row;
|
||||
const merged = { ...row, ...updates };
|
||||
const latched = isTransferOrRootPauseLatched(row.parentTaskId ?? row.id, row.id);
|
||||
if (
|
||||
(latched || row.status === "paused" || row.status === "pausing")
|
||||
&& (updates.status === "transferring" || updates.status === "pending" || updates.status === "queued")
|
||||
) {
|
||||
return { ...merged, status: row.status, speed: 0 };
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
transfersRef.current = transfersRef.current.map((row) => (
|
||||
row.id === taskId ? { ...row, ...updates } : row
|
||||
));
|
||||
scheduleProgressFlush();
|
||||
}, [scheduleProgressFlush]);
|
||||
useEffect(() => () => {
|
||||
@@ -633,7 +468,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
acquireTransferSession,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -767,9 +602,8 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -781,9 +615,8 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -1222,9 +1055,8 @@ export const useSftpTransfers = ({
|
||||
const next = transfersRef.current.map((candidate) => (
|
||||
candidate.id === task.id ? canonicalTask : candidate
|
||||
));
|
||||
// Terminal rows used to setState directly and leave a child frame
|
||||
// queued. That frame later painted the pre-terminal ref.
|
||||
paintTransfersNow(next);
|
||||
transfersRef.current = next;
|
||||
setTransfersState(next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1290,7 +1122,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1675,8 +1507,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2168,9 +2000,8 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2236,7 +2067,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
@@ -2275,16 +2106,11 @@ export const useSftpTransfers = ({
|
||||
// panel list; byte-only ticks update refs without repainting the whole panel.
|
||||
useEffect(() => {
|
||||
return sftpTransferCenterStore.subscribe(() => {
|
||||
const previous = transfersRef.current;
|
||||
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
|
||||
transfersRef.current = ownerRows;
|
||||
if (shouldPaintTransferControlNow(previous, ownerRows)) {
|
||||
paintTransfersNow(ownerRows);
|
||||
return;
|
||||
}
|
||||
scheduleTransfersStateFlush();
|
||||
setTransfersState(ownerRows);
|
||||
});
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
}, [ownerId]);
|
||||
|
||||
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
||||
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
||||
|
||||
@@ -6,7 +6,7 @@ export class TransferOwnerChangedError extends Error {}
|
||||
|
||||
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean; warning?: string } | undefined;
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
@@ -37,17 +37,7 @@ export async function runTransferAndWaitForOwner(
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
|
||||
const result = await start();
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
if (!result?.superseded) {
|
||||
// The invoke response may arrive before completed IPC. Preserve cleanup
|
||||
// details before the caller completes (and may compact) this child.
|
||||
if (result?.warning && !result.error && !result.cancelled) {
|
||||
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||
if (latest && latest.status !== "failed" && latest.status !== "cancelled") {
|
||||
sftpTransferCenterStore.patchTask(task.id, { warning: result.warning });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
|
||||
@@ -33,85 +33,6 @@ test("store aggregates owner snapshots without duplicating tasks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("completion cleanup warnings survive owner snapshots and restored history", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = makeTask("cleanup-warning");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, warning });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.reconnectRequired, false);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a stale renderer completion cannot erase cleanup details");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
assert.equal(restored.getTask(source.id)?.status, "completed");
|
||||
assert.equal(restored.getTask(source.id)?.error, undefined);
|
||||
});
|
||||
|
||||
test("invoke cleanup warnings survive stale live and completion snapshots until an explicit restart", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-snapshot");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.publishOwner("panel", [{ ...source, transferredBytes: 9 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "completed", transferredBytes: 10, endTime: Date.now() }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
store.publishOwner("panel", [{ ...source, status: "queued", transferredBytes: 0, checkpointBytes: 0 }]);
|
||||
assert.equal(store.getTask(source.id)?.warning, undefined, "new attempts do not inherit cleanup warnings");
|
||||
});
|
||||
|
||||
test("a clean completed event cannot erase a warning already returned by the invoke", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const source = makeTask("invoke-warning-event");
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
store.publishOwner("panel", [source]);
|
||||
store.patchTask(source.id, { warning });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
});
|
||||
|
||||
test("late completion enriches a locally completed task with its cleanup warning", () => {
|
||||
let persisted: string | null = null;
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
const source = { ...makeTask("result-first-warning"), phase: "transferring" as const };
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const endTime = Date.now();
|
||||
store.publishOwner("panel", [source]);
|
||||
// The invoke response can settle the renderer before the completed IPC arrives.
|
||||
store.patchTask(source.id, { status: "completed", transferredBytes: 10, endTime });
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribe(() => { notifications += 1; });
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10, endedAt: endTime + 1, warning });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning);
|
||||
assert.equal(store.getTask(source.id)?.status, "completed");
|
||||
assert.equal(store.getTask(source.id)?.endTime, endTime, "late metadata does not replace the terminal outcome");
|
||||
assert.equal(notifications, 1, "the warning is visible without another transfer triggering a render");
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: source.id, transferred: 10 });
|
||||
assert.equal(store.getTask(source.id)?.warning, warning, "a duplicate clean event cannot erase the warning");
|
||||
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restored.getTask(source.id)?.warning, warning);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
for (const status of ["failed", "cancelled"] as const) {
|
||||
test(`late cleanup warnings cannot replace a ${status} terminal outcome`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const endTime = Date.now();
|
||||
const task = { ...makeTask(`late-warning-${status}`, status), error: "original outcome", endTime };
|
||||
store.publishOwner("panel", [task]);
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: task.id, warning: "cleanup warning" });
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
assert.equal(store.getTask(task.id)?.error, "original outcome");
|
||||
assert.equal(store.getTask(task.id)?.warning, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("store routes cancel/retry/prioritize/dismiss to the task owner; pause/resume are process-global", async () => {
|
||||
const calls: string[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
@@ -176,89 +97,6 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
|
||||
assert.equal(store.getTask("background-retry")?.status, "completed");
|
||||
});
|
||||
|
||||
for (const [label, error, expectResume] of [
|
||||
["dead channel", "SFTP channel closed", true],
|
||||
["lost session", "SFTP session lost. Please reconnect.", true],
|
||||
["aborted socket", "read ECONNABORTED", true],
|
||||
["hung up socket", "socket hang up", true],
|
||||
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
|
||||
["client socket", "client-socket", 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");
|
||||
@@ -3655,46 +3493,6 @@ test("legacy 50,000-file history restores cooperatively and converges to one per
|
||||
assert.ok(persisted.length < 10_000, "migrated localStorage payload must stay bounded");
|
||||
});
|
||||
|
||||
for (const indexed of [false, true]) {
|
||||
test(`cooperative ${indexed ? "indexed" : "legacy"} restore retains completed child cleanup warnings`, async () => {
|
||||
const now = Date.now();
|
||||
const warning = "Upload completed; remove backup /target/.netcatty-backup-file manually.";
|
||||
const parent: TransferTask = { ...makeTask("restore-warning-folder", "paused"), isDirectory: true };
|
||||
const children: TransferTask[] = [0, 1].map((index) => ({
|
||||
...makeTask(`restore-warning-child-${index}`, "completed"),
|
||||
parentTaskId: parent.id, endTime: now,
|
||||
warning: index === 0 ? warning : undefined,
|
||||
...(indexed ? { directoryEntryIndex: index, directoryEntryIdentity: String(index).padStart(64, "0") } : {}),
|
||||
}));
|
||||
let persisted = JSON.stringify({ version: 1, padding: "x".repeat(600_000), tasks: [parent, ...children] });
|
||||
const store = createSftpTransferCenterStore({ read: () => persisted, write: (value) => { persisted = value; } });
|
||||
assert.equal(store.getSnapshot().tasks.length, 0, "large history must take cooperative restoration");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let unsubscribe = () => {};
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("timed out waiting for cleanup-warning history migration"));
|
||||
}, 10_000);
|
||||
const check = () => {
|
||||
if (!store.getTask(parent.id)) return;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
unsubscribe = store.subscribe(check);
|
||||
check();
|
||||
});
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(store.getTask("restore-warning-child-0")?.status, "completed");
|
||||
assert.equal(store.getTask("restore-warning-child-1"), undefined, "clean completed children still compact");
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.coveredEntries, 2);
|
||||
assert.equal(store.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
const restarted = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
|
||||
assert.equal(restarted.getTask("restore-warning-child-0")?.warning, warning);
|
||||
assert.equal(restarted.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("live mutations wait for cooperative restore and merge without erasing old rows", async () => {
|
||||
const legacyOnly = makeTask("legacy-only", "paused");
|
||||
const staleCollision = makeTask("collision", "paused");
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./sftp/globalSftpTransferControl";
|
||||
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
@@ -152,7 +151,6 @@ export interface SftpTransferCenterStore {
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
transferred?: number;
|
||||
totalBytes?: number;
|
||||
speed?: number;
|
||||
@@ -290,12 +288,6 @@ export function mergeOwnerPublishedTask(
|
||||
updatedAt: incoming.updatedAt ?? Date.now(),
|
||||
};
|
||||
|
||||
// A panel snapshot may predate cleanup metadata returned by the invoke or
|
||||
// completed IPC. Keep it through this attempt, but not a restart or failure.
|
||||
if (!incomingReset && incoming.status !== "failed" && incoming.status !== "cancelled") {
|
||||
merged.warning = incoming.warning ?? existing.warning;
|
||||
}
|
||||
|
||||
if (incomingReset || incomingTerminal) {
|
||||
return merged;
|
||||
}
|
||||
@@ -373,13 +365,9 @@ export function mergeOwnerPublishedTask(
|
||||
} else if (
|
||||
existingPaused
|
||||
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
|
||||
// A strictly older epoch is a stale snapshot. Equal epochs are how Resume
|
||||
// republishes after the latch drops. While the pause latch is still held,
|
||||
// that republish is a pre-pause panel snapshot and must not reopen the row.
|
||||
&& (
|
||||
incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
|| isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id)
|
||||
)
|
||||
// Only a strictly older epoch is a stale snapshot. Equal/missing epochs must
|
||||
// allow intentional Resume (publishOwner after unlatch) to re-open the row.
|
||||
&& incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
) {
|
||||
// Soft-drain / late progress publish must not un-pause without resume epoch.
|
||||
merged.status = existing.status;
|
||||
@@ -2064,24 +2052,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const controller = ownerId === "background-agent"
|
||||
? undefined
|
||||
: controllers.get(ownerId ?? "");
|
||||
// Live panel task whose connection dropped mid-file: the stage is valid
|
||||
// up to the checkpoint. Continue through the reconnect-resume path a
|
||||
// restored task uses (same id, so the same staged .part) instead of a
|
||||
// fresh id from byte 0, which strands the old stage on the server.
|
||||
// Orphaned rows (no live owner) keep the restart-from-0 contract below.
|
||||
if (controller && canRetryFromCheckpoint(task)) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "interrupted" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: true,
|
||||
speed: 0,
|
||||
endTime: undefined,
|
||||
} : candidate);
|
||||
emit();
|
||||
await this.resume(taskId);
|
||||
return;
|
||||
}
|
||||
if (controller) {
|
||||
await controller.retry(taskId);
|
||||
return;
|
||||
@@ -2281,15 +2251,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (terminal && event.type !== "cancelled") {
|
||||
// Allow a late explicit cancel to stick; ignore everything else.
|
||||
if (event.type === "completed" || event.type === "failed") {
|
||||
// An invoke response can complete the renderer row before its IPC
|
||||
// event arrives. Enrich that same success with cleanup metadata,
|
||||
// without replacing any terminal outcome or reviving stopped work.
|
||||
if (existing.status === "completed" && event.type === "completed"
|
||||
&& event.warning && event.warning !== existing.warning) {
|
||||
tasks = tasks.map((task) => task.id === existing.id ? { ...task, warning: event.warning } : task);
|
||||
emit(true);
|
||||
return;
|
||||
}
|
||||
// Keep existing terminal state (prefer cancelled over late completed).
|
||||
publishRepairedHierarchy();
|
||||
return;
|
||||
@@ -2577,7 +2538,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
? task.checkpointBytes
|
||||
: Math.max(task.checkpointBytes ?? 0, Number(event.checkpointBytes) || 0),
|
||||
error: event.error,
|
||||
warning: event.type === "completed" ? event.warning ?? task.warning : undefined,
|
||||
endTime: event.endedAt ?? Date.now(),
|
||||
speed: 0,
|
||||
reconnectRequired: event.type === "failed" ? task.reconnectRequired : false,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -9,106 +9,6 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
|
||||
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
|
||||
const sessions = ["images", "plain", "other"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
terminalHiddenRendererStore.markSoftHidden("plain");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
|
||||
terminalHiddenRendererStore.clearSoftHidden("plain");
|
||||
terminalHiddenRendererStore.settleEviction("plain", true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
const delivered: string[] = [];
|
||||
const later = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
|
||||
});
|
||||
try {
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
assert.deepEqual(delivered, []);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
} finally {
|
||||
later();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reserved leaving slot counts toward occupancy until it is released", () => {
|
||||
const sessions = ["a", "b", "c", "d"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
|
||||
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
|
||||
|
||||
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
|
||||
|
||||
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
|
||||
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
let markerClearedBeforeSettle = false;
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
|
||||
assert.equal(markerClearedBeforeSettle, true);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
}
|
||||
});
|
||||
@@ -3,85 +3,14 @@ type Listener = () => void;
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
|
||||
const reservedLeavingSlots = new Map<string, number>();
|
||||
let reservationToken = 0;
|
||||
|
||||
function reservedLeavingCount(): number {
|
||||
let extra = 0;
|
||||
for (const sessionId of reservedLeavingSlots.keys()) {
|
||||
if (!softHiddenSessions.has(sessionId)) extra += 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
type EvictionWaiter = {
|
||||
promise: Promise<boolean>;
|
||||
resolve: (completed: boolean) => void;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
const evictionWaiters = new Map<string, EvictionWaiter>();
|
||||
|
||||
function finishEviction(sessionId: string, completed: boolean): void {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
if (!waiter || waiter.settled) return;
|
||||
waiter.settled = true;
|
||||
evictionWaiters.delete(sessionId);
|
||||
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
|
||||
waiter.resolve(completed);
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function listSoftHiddenOldestFirst(): string[] {
|
||||
return [...softHiddenSessions.entries()]
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.map(([sessionId]) => sessionId);
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
/**
|
||||
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
|
||||
* yet in the marker set. Keep-count decisions use this so a second timer
|
||||
* cannot take the slot a pending eviction already claimed.
|
||||
*/
|
||||
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
|
||||
|
||||
/**
|
||||
* Claim one keep slot for a leaving tab before awaiting eviction.
|
||||
* A session already in the marker set is not counted twice. A later claim
|
||||
* for the same id replaces the token; only that token can release it.
|
||||
*/
|
||||
reserveLeavingSlot: (sessionId: string): number => {
|
||||
const token = ++reservationToken;
|
||||
if (sessionId && !softHiddenSessions.has(sessionId)) {
|
||||
reservedLeavingSlots.set(sessionId, token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
releaseLeavingSlot: (sessionId: string, token: number) => {
|
||||
if (reservedLeavingSlots.get(sessionId) === token) {
|
||||
reservedLeavingSlots.delete(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* True when retaining this leaving tab would stay within keepCount.
|
||||
* Its own reservation is not counted against the slots held by others.
|
||||
*/
|
||||
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
|
||||
if (!(keepCount > 0)) return false;
|
||||
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
|
||||
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
|
||||
return others < keepCount;
|
||||
},
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
@@ -94,12 +23,18 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
return listSoftHiddenOldestFirst()[0] ?? null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
@@ -107,44 +42,6 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
/** True while this session's hibernate has been requested and not settled. */
|
||||
hasPendingEviction: (sessionId: string): boolean => {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
return waiter != null && !waiter.settled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask one soft-hidden session to upgrade. Resolves true only when
|
||||
* settleEviction reports that hibernation finished. Clearing the marker
|
||||
* during wake is not success: drain, snapshot, or alternate-screen failure
|
||||
* can still restore that renderer. A synchronous refusal (marker still
|
||||
* present, including inline images) resolves false and drops an unconsumed
|
||||
* request so a later emit cannot upgrade it.
|
||||
*/
|
||||
evictSoftHidden: (sessionId: string): Promise<boolean> => {
|
||||
const existing = evictionWaiters.get(sessionId);
|
||||
if (existing) return existing.promise;
|
||||
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise!: (completed: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
|
||||
evictionWaiters.set(sessionId, waiter);
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
|
||||
finishEviction(sessionId, false);
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** Record whether the in-flight eviction's hibernate actually completed. */
|
||||
settleEviction: (sessionId: string, completed: boolean) => {
|
||||
finishEviction(sessionId, completed);
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -120,12 +116,11 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
await syncExternalMcpConfig(bridge);
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -457,334 +452,3 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -41,7 +39,6 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -79,7 +76,6 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -114,138 +110,6 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -255,11 +119,9 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -284,7 +146,6 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -524,7 +385,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -538,7 +398,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -547,7 +406,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -557,44 +415,23 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,18 +453,10 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -636,18 +465,6 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -699,47 +516,24 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}
|
||||
});
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -748,7 +542,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -793,5 +587,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled, error };
|
||||
return { enabled, setEnabled };
|
||||
}
|
||||
@@ -125,55 +125,6 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
|
||||
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
|
||||
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 900, 0, 400, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
|
||||
const editor = panel(f, 450, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
|
||||
f.resize(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
|
||||
editor.parentElement!.remove(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 0, 0, 300, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
const select = f.overlay(tooltip, 140, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
|
||||
select.remove();
|
||||
f.overlay(tooltip, 320, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
|
||||
'pictures are placed where the narrowed page sits inside the pane');
|
||||
});
|
||||
|
||||
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const middle = panel(f, 300, 0, 100, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
middle.parentElement!.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
panel(f, 250, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -71,21 +70,12 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
// Overlay panels stay open while the user works, so the live page moves
|
||||
// beside them when it can and only falls back to a picture otherwise.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
||||
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
||||
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
||||
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
||||
// inside the host editor) no longer touch the live page.
|
||||
const covered = modal || (!!rect && !live)
|
||||
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -100,11 +90,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const pane = live || rect;
|
||||
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
||||
// Pictures are relative to the native page, which may sit beside a panel.
|
||||
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
||||
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -116,9 +102,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
||||
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
||||
})) } : null);
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -135,15 +119,14 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type Raster = { light: string; dark?: string };
|
||||
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
|
||||
const pluginId = plugin?.id;
|
||||
const kind = plugin?.manifest?.icon?.kind;
|
||||
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
|
||||
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pluginId || kind !== 'package') return;
|
||||
let cancelled = false;
|
||||
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
|
||||
if (!cancelled) setLoaded({ key, value });
|
||||
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
|
||||
return () => { cancelled = true; };
|
||||
}, [key, pluginId, kind]);
|
||||
return loaded?.key === key ? loaded.value : null;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { usePluginCatalog } from './usePluginCatalog';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
|
||||
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
|
||||
},
|
||||
} });
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
context.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginCatalog>;
|
||||
let renders = 0;
|
||||
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
|
||||
assert.equal(state.readmeStatus, 'loading');
|
||||
await act(async () => calls[0].request.reject(new Error('temporary failure')));
|
||||
assert.equal(state.readmeStatus, 'error');
|
||||
assert.match(state.readmeError!, /temporary failure/);
|
||||
await act(async () => state.retryReadme());
|
||||
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[1].request.resolve(''));
|
||||
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
|
||||
assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[3].request.resolve('# Second'));
|
||||
await act(async () => calls[2].request.reject(new Error('stale failure')));
|
||||
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.unmount());
|
||||
const unmountedRenders = renders;
|
||||
await act(async () => calls[4].request.resolve('late content'));
|
||||
assert.equal(renders, unmountedRenders);
|
||||
});
|
||||
|
||||
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
|
||||
let offline = true;
|
||||
const reads: unknown[][] = []; const installs: unknown[][] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
dispatchEvent() {}, netcatty: {
|
||||
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
|
||||
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
|
||||
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
|
||||
},
|
||||
} });
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
context.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginCatalog>;
|
||||
function Probe() { state = usePluginCatalog(entry.id); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
|
||||
assert.equal(state.source, 'cached');
|
||||
offline = false;
|
||||
await act(async () => state.retryList());
|
||||
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second']]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
export function usePluginCatalog(selectedId: string | null) {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [listAttempt, setListAttempt] = useState(0);
|
||||
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const installing = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setListStatus('loading'); setListError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
let active = true;
|
||||
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
|
||||
setReadmeState({ ...base, status: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
error: installError?.id === selectedId ? installError.message : null,
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
|
||||
finally { installing.current = false; if (mounted.current) setBusy(false); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<HostInteractionPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
current.current = event; setPrompt(event);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
});
|
||||
void bridge.setPluginInteractionReady(true).catch(() => {});
|
||||
return () => {
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
void bridge.setPluginInteractionReady?.(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
|
||||
const active = current.current;
|
||||
if (!active || pending.current) return;
|
||||
pending.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
|
||||
const accepted = await bridge.respondPluginInteraction({
|
||||
requestId: active.requestId, actionToken: active.actionToken, action,
|
||||
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
|
||||
});
|
||||
if (!accepted && current.current?.actionToken === active.actionToken) {
|
||||
current.current = null; setPrompt(null);
|
||||
}
|
||||
} catch {
|
||||
if (current.current?.actionToken === active.actionToken) setError(true);
|
||||
} finally {
|
||||
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
|
||||
}
|
||||
}, [value]);
|
||||
return { prompt, value, setValue, error, busy, respond };
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { usePluginManager } from './usePluginManager';
|
||||
|
||||
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let fail = false;
|
||||
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
addEventListener() {}, removeEventListener() {}, netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
|
||||
},
|
||||
} });
|
||||
context.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginManager>;
|
||||
function Probe() { state = usePluginManager(); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, true);
|
||||
fail = true;
|
||||
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
|
||||
assert.equal(state.statusKnown, false);
|
||||
assert.match(state.error ?? '', /synthetic status read failed/);
|
||||
fail = false; plugin.enabled = false;
|
||||
await act(async () => { await state.refresh(); });
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, false);
|
||||
} finally { await act(async () => { renderer.unmount(); }); }
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [statusKnown, setStatusKnown] = useState(false);
|
||||
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setStatusKnown(true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
} catch (cause) {
|
||||
if (current === generation.current) {
|
||||
setStatusKnown(false);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
update();
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
|
||||
window.addEventListener('netcatty:plugin-packages-changed', update);
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
importPackage: () => run(async (bridge) => {
|
||||
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
|
||||
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
|
||||
if (path) await bridge.installPluginPackage(path, { enable: false });
|
||||
}),
|
||||
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -54,8 +54,6 @@ export interface ServerStats {
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
|
||||
diskWriteSpeed?: number | null;
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
diskTotal: number | null; // Total disk in GB
|
||||
@@ -242,8 +240,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
diskPercent: stats.diskPercent ?? null,
|
||||
diskUsed: stats.diskUsed ?? null,
|
||||
diskTotal: stats.diskTotal ?? null,
|
||||
diskReadSpeed: stats.diskReadSpeed ?? null,
|
||||
diskWriteSpeed: stats.diskWriteSpeed ?? null,
|
||||
disks: stats.disks || [],
|
||||
netRxSpeed: stats.netRxSpeed || 0,
|
||||
netTxSpeed: stats.netTxSpeed || 0,
|
||||
|
||||
@@ -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, hasSessionLog: false, error: "Session log bridge unavailable" };
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
Loaded 100 of 639 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user