mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
208
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c3ee4474e | ||
|
|
50ca182e1e | ||
|
|
04a63c653a | ||
|
|
f4b5417909 | ||
|
|
e2f499b0f9 | ||
|
|
1bb6bc895c | ||
|
|
5805d15746 | ||
|
|
bd8476fb34 | ||
|
|
c74daf0102 | ||
|
|
f1425c8d5a | ||
|
|
fc55e9ac17 | ||
|
|
025ce741c2 | ||
|
|
323ff0b16a | ||
|
|
6ece7e8222 | ||
|
|
e3894eb850 | ||
|
|
38c7376515 | ||
|
|
1b8228b23d | ||
|
|
b2c518a6f1 | ||
|
|
ea36f46d1d | ||
|
|
6d0d39e678 | ||
|
|
08089323a9 | ||
|
|
91db655a1a | ||
|
|
d51cd2714d | ||
|
|
3fd954d2bf | ||
|
|
ff28c9b412 | ||
|
|
7006fa214e | ||
|
|
459f3a5278 | ||
|
|
6c8edddfa8 | ||
|
|
5af2c86f8f | ||
|
|
7b1151f976 | ||
|
|
d21016b91a | ||
|
|
b62a0b3a8f | ||
|
|
3560a04fa6 | ||
|
|
a373f57905 | ||
|
|
e376190a70 | ||
|
|
037a2f651b | ||
|
|
1a4a022a8e | ||
|
|
fd177e8453 | ||
|
|
dee468dcfa | ||
|
|
c666fdb156 | ||
|
|
b4111cf970 | ||
|
|
69497ccc6a | ||
|
|
078353dc87 | ||
|
|
fb7adc3bfc | ||
|
|
8d6ef72006 | ||
|
|
85683757e5 | ||
|
|
7224ad37c8 | ||
|
|
598f5cf796 | ||
|
|
ed92944af0 | ||
|
|
e2b3daac9a | ||
|
|
85bddba538 | ||
|
|
66e87a95f1 | ||
|
|
7cce9c9dbb | ||
|
|
954b60acf2 | ||
|
|
73284582f5 | ||
|
|
33853398d4 | ||
|
|
9497cd65fc | ||
|
|
95a0186d90 | ||
|
|
626e0ce5bd | ||
|
|
71d378c9b8 | ||
|
|
105b7f16bc | ||
|
|
ec11989d52 | ||
|
|
fd3e82386a | ||
|
|
2b120c0d41 | ||
|
|
318577cd72 | ||
|
|
6398360854 | ||
|
|
e3b2e82f88 | ||
|
|
9e07e9e7b9 | ||
|
|
0261931f95 | ||
|
|
62e9c9ed12 | ||
|
|
1b4e826e15 | ||
|
|
f469513579 | ||
|
|
707972d976 | ||
|
|
8573027e61 | ||
|
|
ff65339e04 | ||
|
|
a70c43e302 | ||
|
|
7b3b8214a9 | ||
|
|
cb06857480 | ||
|
|
0982c213e7 | ||
|
|
0d21e37a19 | ||
|
|
9082aa08a4 | ||
|
|
dffcefc308 | ||
|
|
6bee56220c | ||
|
|
0fa0c6a0a4 | ||
|
|
12a3ae07ef | ||
|
|
ec2f0d0bce | ||
|
|
3738565b1b | ||
|
|
d98a22f49b | ||
|
|
08009d4ce0 | ||
|
|
f841ab4778 | ||
|
|
2fa13257e7 | ||
|
|
8ef37454ae | ||
|
|
7e4b3c293a | ||
|
|
bf9fa571ee | ||
|
|
477bc0c4ec | ||
|
|
741299f71d | ||
|
|
349755aaf7 | ||
|
|
199afd56f6 | ||
|
|
246fc39536 | ||
|
|
c16f0c43d4 | ||
|
|
4df94736d0 | ||
|
|
91c3defe66 | ||
|
|
faf74ae9be | ||
|
|
cbace2f2ac | ||
|
|
a52726aef4 | ||
|
|
605dc94b13 | ||
|
|
9e74618f2e | ||
|
|
b46910d46e | ||
|
|
d8ea77c28b | ||
|
|
210879b848 | ||
|
|
db0238ce6e | ||
|
|
6f30bbefb5 | ||
|
|
843c81ca44 | ||
|
|
f1e59913c2 | ||
|
|
a95eb62cb1 | ||
|
|
7205c40a7c | ||
|
|
7cf8ebac7e | ||
|
|
48c721bf43 | ||
|
|
756f332db5 | ||
|
|
2ee55d5b4b | ||
|
|
2e588630a1 | ||
|
|
e9cc0d54d2 | ||
|
|
57cd40a192 | ||
|
|
e5dc30ba2f | ||
|
|
e9d2963fe5 | ||
|
|
d0af56b4dd | ||
|
|
09f7fcf248 | ||
|
|
9e63ffd99e | ||
|
|
f24b5f4202 | ||
|
|
26278a8694 | ||
|
|
e10e5ccbc2 | ||
|
|
d9ca951cc9 | ||
|
|
00ec0d04c4 | ||
|
|
dd5749460f | ||
|
|
00c9cf440f | ||
|
|
6cdae7af9c | ||
|
|
3b1a4adc82 | ||
|
|
ac65d465cf | ||
|
|
2738adb2f8 | ||
|
|
688f544034 | ||
|
|
f9ad1ddfe0 | ||
|
|
a0d032ec3c | ||
|
|
e9b0dadd2a | ||
|
|
be511f8546 | ||
|
|
214bc8e7f1 | ||
|
|
758029bc29 | ||
|
|
3e17cdd62e | ||
|
|
8b5db49001 | ||
|
|
87f794d17d | ||
|
|
cb35353fde | ||
|
|
ce86e8e426 | ||
|
|
0714ca5ee0 | ||
|
|
b081673c2b | ||
|
|
131ed03ffa | ||
|
|
1fc98cd654 | ||
|
|
28f7193c4a | ||
|
|
dbdc8631dd | ||
|
|
a05e004259 | ||
|
|
cae4169948 | ||
|
|
7ec9cd652b | ||
|
|
3987e465b6 | ||
|
|
d8761c156e | ||
|
|
47bb5c2b55 | ||
|
|
dc05697ede | ||
|
|
51d4d97419 | ||
|
|
530f57c95c | ||
|
|
da0938c209 | ||
|
|
9e6846e0d5 | ||
|
|
0b96d70702 | ||
|
|
e7d7a12a91 | ||
|
|
86d8c5279d | ||
|
|
e0cb6034fe | ||
|
|
b54c5e3a9b | ||
|
|
818c6fd551 | ||
|
|
15b16ab7b2 | ||
|
|
f83b5f6693 | ||
|
|
8017c144fe | ||
|
|
462ffbb182 | ||
|
|
4288948d94 | ||
|
|
66deeb8dd9 | ||
|
|
f0b3bca13f | ||
|
|
6c05f0b450 | ||
|
|
aaced70fc6 | ||
|
|
891faff31c | ||
|
|
08557ff596 | ||
|
|
7f75662c9f | ||
|
|
561f4b3c71 | ||
|
|
6ea6c23e88 | ||
|
|
55c2b18738 | ||
|
|
8be56264fc | ||
|
|
d7a0c8ed8d | ||
|
|
65afea01e0 | ||
|
|
fe2f14f032 | ||
|
|
1a9c57b0a9 | ||
|
|
058727b0d9 | ||
|
|
35ed0b6dbd | ||
|
|
ab80d04039 | ||
|
|
aaac962179 | ||
|
|
5d61462b20 | ||
|
|
8cc8d938b8 | ||
|
|
1babda6fa9 | ||
|
|
0eebc77b1b | ||
|
|
7069bdb1c8 | ||
|
|
10ad14223f | ||
|
|
f7f0d804af | ||
|
|
4ba196510d | ||
|
|
fd54339367 | ||
|
|
28b9a8db89 |
No files matched your search
@@ -877,6 +877,7 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
@@ -1695,6 +1696,7 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
|
||||
@@ -76,6 +76,7 @@ on:
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
- "scripts/nodePtyConptyPatch.cjs"
|
||||
- "scripts/patch-xterm-macos-column-selection.cjs"
|
||||
- "scripts/patch-xterm-foreground-intense.cjs"
|
||||
- "scripts/patch-xterm-webgl-atlas.cjs"
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
@@ -695,11 +696,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
# Bullseye LTS ended on 2026-08-31; its security index is now frozen.
|
||||
# Pin the final snapshot and waive expiry only for this archived source.
|
||||
# APT still verifies its signature and every package's checksum.
|
||||
sed -i '/ bullseye-security /d' /etc/apt/sources.list
|
||||
printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260901T000000Z/ bullseye-security main\n' >> /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|---------|-------------------|--------|
|
||||
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
|
||||
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
|
||||
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
|
||||
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
|
||||
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
|
||||
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
|
||||
**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 (internal preview)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
@@ -81,6 +82,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
|
||||
@@ -220,6 +220,30 @@ Windows リリースは未署名の場合があります。
|
||||
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### Scoop(Windows x64、コミュニティ管理)
|
||||
|
||||
[Scoop](https://scoop.sh/) をインストール済みの場合、コミュニティが管理する [lemon バケット](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) から Netcatty をインストールできます。このバケットは Netcatty の公式 GitHub Releases からインストーラーをダウンロードします:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新またはアンインストールの前に Netcatty を終了してください:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
アンインストールする場合:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
これはサードパーティ製パッケージであり、Netcatty の公式バケットではありません。更新は GitHub Releases より遅れる場合があります。現在は x64 のみに対応しています。設定は `%APPDATA%\netcatty` に保存され、更新や通常のアンインストール後も残ります。`scoop uninstall netcatty --purge` はこれらの設定も削除します。この方法でインストールした場合は、Scoop で更新を管理してください。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
|
||||
|
||||
@@ -127,6 +127,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
@@ -221,6 +222,30 @@ application and integration are complete.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
Exit Netcatty before updating or uninstalling:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
This is a third-party package, not an official Netcatty bucket, and updates may lag behind GitHub Releases. It currently supports x64 only. Settings remain in `%APPDATA%\netcatty` across updates and normal uninstalls; `scoop uninstall netcatty --purge` also deletes those settings. Use Scoop to manage updates for this installation.
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 发布文件可能仍未签名。
|
||||
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### Scoop(Windows x64,社区维护)
|
||||
|
||||
安装 [Scoop](https://scoop.sh/) 后,可以通过社区维护的 [lemon 软件源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安装 Netcatty。该软件源从 Netcatty 官方 GitHub Releases 下载安装文件:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或卸载前,请先退出 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
这是第三方软件包,并非 Netcatty 官方软件源,更新可能晚于 GitHub Releases。目前仅支持 x64。设置保存在 `%APPDATA%\netcatty`,更新和普通卸载会保留这些设置;`scoop uninstall netcatty --purge` 还会删除这些设置。通过此方式安装后,请使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 發行檔可能仍未簽章。
|
||||
|
||||
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
|
||||
|
||||
### Scoop(Windows x64,社群維護)
|
||||
|
||||
安裝 [Scoop](https://scoop.sh/) 後,可以透過社群維護的 [lemon 軟體來源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安裝 Netcatty。此軟體來源從 Netcatty 官方 GitHub Releases 下載安裝檔:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或解除安裝前,請先結束 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
解除安裝:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
這是第三方套件,並非 Netcatty 官方軟體來源,更新可能晚於 GitHub Releases。目前僅支援 x64。設定儲存在 `%APPDATA%\netcatty`,更新和一般解除安裝會保留這些設定;`scoop uninstall netcatty --purge` 也會刪除這些設定。透過此方式安裝後,請使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
|
||||
|
||||
@@ -1,57 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
|
||||
|
||||
test('batch tab close removes standalone sessions in one state update', async () => {
|
||||
const closedSessionBatches: string[][] = [];
|
||||
const probedSessionBatches: string[][] = [];
|
||||
const closeTabsInFlightRef = { current: false };
|
||||
const sessions = [
|
||||
{ id: 's1', protocol: 'ssh' },
|
||||
{ id: 's2', protocol: 'ssh' },
|
||||
{ id: 's3', protocol: 'ssh' },
|
||||
];
|
||||
function setup() {
|
||||
const closed: string[] = [];
|
||||
const probes: string[][] = [];
|
||||
const editors = [{ id: 'e', sessionId: 'connection', sftpTabId: 'pane' }];
|
||||
let active = 's1';
|
||||
const ctx = {
|
||||
closeLogView: (id: string) => { closed.push(id); },
|
||||
closeSessions: (ids: string[]) => { closed.push(...ids); },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: (id: string) => { closed.push(id); },
|
||||
confirmIfBusyLocalTerminal: async (ids: string[]) => { probes.push(ids); return true; },
|
||||
logViews: [{ id: 'log' }],
|
||||
sessions: [{ id: 's1', workspaceId: null as string | null }, { id: 's2', workspaceId: null as string | null }],
|
||||
workspaces: [{ id: 'ws' }],
|
||||
activeTabStore: { getActiveTabId: () => active, setActiveTabId: (id: string) => { active = id; } },
|
||||
editorTabStore: { getTabs: () => editors },
|
||||
pluginViewTabStore: {
|
||||
getTab: (id: string) => id === 'plugin',
|
||||
close: (id: string) => { closed.push(id); },
|
||||
},
|
||||
handleRequestCloseEditorTabRef: { current: async (id: string) => {
|
||||
closed.push(`editor:${id}`);
|
||||
editors.splice(editors.findIndex((editor) => editor.id === id), 1);
|
||||
return true;
|
||||
} },
|
||||
findEditorSftpOwnerTabId: (_connection?: string, _pane?: string): string | null => null,
|
||||
orderedTabsWithEditors: ['s1', 's2', 'ws', 'editor:e', 'log', 'plugin'],
|
||||
};
|
||||
return { ctx, closed, probes, run: (ids: string[]) => closeTabsBatchImpl(() => ctx, ids) };
|
||||
}
|
||||
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => {},
|
||||
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
|
||||
closeTabsInFlightRef,
|
||||
closeWorkspace: () => {},
|
||||
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
|
||||
probedSessionBatches.push(sessionIds);
|
||||
return true;
|
||||
},
|
||||
logViews: [],
|
||||
sessions,
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1', 's2', 's3'],
|
||||
);
|
||||
|
||||
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.equal(closeTabsInFlightRef.current, false);
|
||||
assert.equal(result, true);
|
||||
test('batch closes sessions, workspaces, editors, logs and plugins and selects surviving tab', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
assert.equal(await run(['s1', 'ws', 'editor:e', 'log', 'plugin']), true);
|
||||
assert.deepEqual(closed, ['editor:e', 's1', 'ws', 'log', 'plugin']);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's2');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
test('batch tab close reports cancellation before mutating any tab', async () => {
|
||||
let mutated = false;
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => { mutated = true; },
|
||||
closeSessions: () => { mutated = true; },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: () => { mutated = true; },
|
||||
confirmIfBusyLocalTerminal: async () => false,
|
||||
logViews: [],
|
||||
sessions: [{ id: 's1', protocol: 'local' }],
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1'],
|
||||
);
|
||||
test('busy cancellation happens before any editor or other tab mutation', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.confirmIfBusyLocalTerminal = async () => false;
|
||||
assert.equal(await run(ctx.orderedTabsWithEditors), false);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(mutated, false);
|
||||
test('cancelled editor preserves its owner and active focus', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.deepEqual(closed, ['s2', 'ws', 'log', 'plugin']);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
});
|
||||
|
||||
test('editor outside close-left range preserves its owner', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
await run(['s1']);
|
||||
assert.deepEqual(closed, []);
|
||||
});
|
||||
|
||||
test('preserved workspace containing an editor owner remains focused', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
ctx.sessions[0].workspaceId = 'ws';
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.activeTabStore.setActiveTabId('ws');
|
||||
await run(['ws']);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'ws');
|
||||
});
|
||||
|
||||
test('cancelled editor becomes focus destination when other tabs close', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'editor:e');
|
||||
});
|
||||
|
||||
for (const phase of ['busy', 'editor'] as const) {
|
||||
test(`overlapping batch is ignored during ${phase} prompt, including editor-only batches`, async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
let release!: (result: boolean) => void;
|
||||
const pending = new Promise<boolean>((resolve) => { release = resolve; });
|
||||
if (phase === 'busy') ctx.confirmIfBusyLocalTerminal = () => pending;
|
||||
else ctx.handleRequestCloseEditorTabRef.current = () => pending;
|
||||
const first = run(phase === 'busy' ? ['s1'] : ['editor:e']);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, true);
|
||||
assert.equal(await run(['s2', 'plugin']), false);
|
||||
assert.deepEqual(closed, []);
|
||||
release(false);
|
||||
await first;
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('failed prompt releases guard so next batch can run', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => { throw new Error('save failed'); };
|
||||
await assert.rejects(run(['editor:e']), /save failed/);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
assert.equal(await run(['s1']), true);
|
||||
});
|
||||
@@ -698,3 +698,51 @@ test('connection log host snapshot includes custom host icon fields', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
|
||||
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
|
||||
{ name: 'hidden active session', active: 'hidden', sessions: [{ id: 'one' }, { id: 'two' }, { id: 'hidden', hiddenFromTabs: true }], available: true, expected: null },
|
||||
{ name: 'non-terminal tab', active: 'vault', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: null },
|
||||
{ name: 'workspace', active: 'workspace', sessions: [{ id: 'one', workspaceId: 'workspace' }, { id: 'two', workspaceId: 'workspace' }], available: false, expected: 'workspace' },
|
||||
]) {
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`broadcast shortcut ${scheme}: ${scenario.name} only consumes an executed toggle`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
let globalEnabled = false;
|
||||
let workspaceEnabled = false;
|
||||
const toggles: string[] = [];
|
||||
const event = {
|
||||
key: 'b', code: 'KeyB', ctrlKey: scheme === 'pc', metaKey: scheme === 'mac',
|
||||
altKey: false, shiftKey: false, target, composedPath: () => [target],
|
||||
preventDefault: () => { prevented += 1; },
|
||||
stopPropagation: () => { stopped += 1; },
|
||||
} as unknown as KeyboardEvent;
|
||||
const actionCtx = {
|
||||
activeTabStore: { getActiveTabId: () => scenario.active },
|
||||
editorTabs: [], orderedTabs: [], settings: {}, toEditorTabId: (id: string) => id,
|
||||
sessions: scenario.sessions,
|
||||
workspaces: scenario.active === 'workspace' ? [{ id: 'workspace' }] : [],
|
||||
canUseGlobalBroadcast: scenario.available,
|
||||
toggleGlobalBroadcast: () => { globalEnabled = !globalEnabled; toggles.push('global'); },
|
||||
toggleBroadcast: (id: string) => { assert.equal(id, 'workspace'); workspaceEnabled = !workspaceEnabled; toggles.push('workspace'); },
|
||||
};
|
||||
const invoke = () => handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false, closeTabKeyStr: '', hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
|
||||
executeHotkeyAction: (action: string, e: KeyboardEvent) => executeHotkeyActionImpl(() => actionCtx, action, e),
|
||||
}), event);
|
||||
invoke();
|
||||
assert.equal(globalEnabled, scenario.expected === 'global');
|
||||
assert.equal(workspaceEnabled, scenario.expected === 'workspace');
|
||||
invoke();
|
||||
assert.equal(globalEnabled, false);
|
||||
assert.equal(workspaceEnabled, false);
|
||||
assert.deepEqual(toggles, scenario.expected ? [scenario.expected, scenario.expected] : []);
|
||||
assert.equal(prevented, scenario.expected ? 2 : 0);
|
||||
assert.equal(stopped, scenario.expected ? 2 : 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
+102
-45
@@ -3,14 +3,17 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
@@ -151,7 +154,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, knownHosts);
|
||||
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -310,6 +313,16 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
@@ -522,7 +535,11 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
||||
try {
|
||||
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
||||
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
||||
allowSshProbe = shouldProbeSessionCwd({
|
||||
isNetworkDevice: false,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
} catch {
|
||||
allowSshProbe = false;
|
||||
}
|
||||
@@ -677,48 +694,86 @@ export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, s
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
||||
{
|
||||
if (targetIds.length === 0) return true;
|
||||
if (closeTabsInFlightRef.current) return false;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return false;
|
||||
const standaloneSessionIds = targetIds.filter((tabId) => (
|
||||
sessions.some((session) => session.id === tabId)
|
||||
));
|
||||
if (standaloneSessionIds.length > 0) {
|
||||
closeSessions(standaloneSessionIds);
|
||||
}
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
/**
|
||||
* Expand batch-close target ids into the terminal session ids whose local
|
||||
* shells the busy probe must inspect: workspace ids contribute every session
|
||||
* they contain; standalone session ids pass through.
|
||||
*/
|
||||
export function collectBatchBusyProbeSessionIds(
|
||||
sessions: readonly { id: string; workspaceId?: string | null }[],
|
||||
workspaces: readonly { id: string }[],
|
||||
targetIds: readonly string[],
|
||||
): string[] {
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
return sessionIdsToProbe;
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const {
|
||||
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
|
||||
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
|
||||
activeTabStore, editorTabStore, pluginViewTabStore,
|
||||
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
|
||||
} = getCtx();
|
||||
if (targetIds.length === 0) return true;
|
||||
if (closeTabsInFlightRef.current) return false;
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const activeBeforeClose = activeTabStore.getActiveTabId();
|
||||
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
|
||||
const editorIds = targetIds.filter(isEditorTabId);
|
||||
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id) && !isEditorTabId(id));
|
||||
// Cancel busy-terminal confirmation before making any changes, including editors.
|
||||
if (regularIds.length && !await confirmIfBusyLocalTerminal(
|
||||
collectBatchBusyProbeSessionIds(sessions, workspaces, regularIds),
|
||||
)) return false;
|
||||
|
||||
const closedEditorIds: string[] = [];
|
||||
for (const id of editorIds) {
|
||||
if (await handleRequestCloseEditorTabRef.current(fromEditorTabId(id))) closedEditorIds.push(id);
|
||||
}
|
||||
|
||||
// Owners must remain mounted while any editor survives (cancelled or outside
|
||||
// the range), because unmounting their SFTP panel discards its editors.
|
||||
const keepTabIds = new Set<string>();
|
||||
for (const editor of editorTabStore.getTabs()) {
|
||||
const owner = findEditorSftpOwnerTabId(editor.sessionId, editor.sftpTabId);
|
||||
if (owner) keepTabIds.add(owner);
|
||||
}
|
||||
const effectiveRegularIds = regularIds.filter((id) => !keepTabIds.has(id) && !(
|
||||
workspaces.some((workspace) => workspace.id === id) &&
|
||||
sessions.some((session) => session.workspaceId === id && keepTabIds.has(session.id))
|
||||
));
|
||||
const standaloneSessionIds = effectiveRegularIds.filter((id) => sessions.some((session) => session.id === id));
|
||||
if (standaloneSessionIds.length) closeSessions(standaloneSessionIds);
|
||||
for (const id of effectiveRegularIds) {
|
||||
if (workspaces.some((workspace) => workspace.id === id)) closeWorkspace(id);
|
||||
else if (logViews.some((logView) => logView.id === id)) closeLogView(id);
|
||||
}
|
||||
for (const id of pluginIds) pluginViewTabStore.close(id);
|
||||
|
||||
const closingTabIds = new Set([...effectiveRegularIds, ...closedEditorIds, ...pluginIds]);
|
||||
if (closingTabIds.has(activeBeforeClose)) {
|
||||
activeTabStore.setActiveTabId(resolveBatchTabCloseFocus({
|
||||
orderedTabIds: orderedTabsWithEditors,
|
||||
closingTabIds,
|
||||
activeTabId: activeBeforeClose,
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
@@ -922,12 +977,14 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
return true;
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
session.id === currentId && !session.workspaceId && !session.hiddenFromTabs
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'openSettings':
|
||||
handleOpenSettingsRef.current();
|
||||
|
||||
@@ -225,3 +225,9 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
});
|
||||
|
||||
test('terminal system detection routes through the owner of temporary hosts', () => {
|
||||
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
|
||||
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
|
||||
test('terminal keyboard focus tracking is always published to the main process', () => {
|
||||
assert.match(
|
||||
appSideEffectsSource,
|
||||
/useTerminalKeyboardFocus\(\s*\)/,
|
||||
'the terminal keyboard focus signal must be unconditional',
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal keyboard focus is not gated on hotkey scheme or font zoom settings', () => {
|
||||
// When the signal is turned off, the main process falls back to window zoom
|
||||
// for Ctrl+= / Ctrl+- / Ctrl+0 while the user is typing in a terminal, so
|
||||
// "disable terminal zoom" and per-binding overrides stop having any effect
|
||||
// on those chords (#3327).
|
||||
assert.doesNotMatch(appSideEffectsSource, /useTerminalKeyboardFocus\(\s*hotkeyScheme/);
|
||||
assert.doesNotMatch(
|
||||
appSideEffectsSource,
|
||||
/useTerminalKeyboardFocus\(\s*[^)\s][^)]*disableTerminalFontZoom/,
|
||||
);
|
||||
});
|
||||
@@ -21,11 +21,11 @@ import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
pluginViewTabStore,
|
||||
resolveBatchTabCloseFocus,
|
||||
usePluginViewTabs,
|
||||
} from '../state/pluginViewTabStore';
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -57,7 +57,7 @@ import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import { prepareSyncPayloadApply, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -139,14 +139,17 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
// Always publish terminal keyboard focus to the main process. When font zoom
|
||||
// is disabled (or hotkeys are off) the renderer ignores the font chords, so
|
||||
// the main process must keep handing them to the page instead of falling
|
||||
// back to window zoom while the user is typing in a terminal (#3327).
|
||||
useTerminalKeyboardFocus();
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
@@ -202,6 +205,7 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
updateHostLastConnected,
|
||||
updateHostDistro,
|
||||
importDataFromString,
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
@@ -248,6 +252,8 @@ export function AppSideEffects() {
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
logViews,
|
||||
closeLogView,
|
||||
copySession,
|
||||
@@ -498,8 +504,8 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
prepareApply: () =>
|
||||
prepareSyncPayloadApply(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -521,14 +527,17 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
prepareApply: async () => {
|
||||
const portable = stripSyncPayloadEncryptedCredentials(payload);
|
||||
await applySyncPayload(portable, {
|
||||
const applyPreparedPayload = await prepareSyncPayloadApply(portable, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}, { currentHosts: hosts });
|
||||
await commitReplica();
|
||||
return async () => {
|
||||
await applyPreparedPayload();
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
@@ -899,6 +908,8 @@ export function AppSideEffects() {
|
||||
// array thrash does not rebuild appTerminalDomain (see domain isolation).
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const canUseGlobalBroadcastRef = useRef(canUseGlobalBroadcast);
|
||||
canUseGlobalBroadcastRef.current = canUseGlobalBroadcast;
|
||||
// Logs live in connectionLogsStore — keep a ref fresh without re-rendering App.
|
||||
const connectionLogsRef = useRef(getConnectionLogsSnapshot().connectionLogs);
|
||||
useEffect(() => {
|
||||
@@ -1009,27 +1020,18 @@ export function AppSideEffects() {
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
// Used by the "Close all / Close others / Close to the left / Close to the
|
||||
// right" context-menu actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
const closingTabIds = new Set(targetIds);
|
||||
const activeBeforeClose = activeTabStore.getActiveTabId();
|
||||
const focusAfterClose = resolveBatchTabCloseFocus({
|
||||
orderedTabIds: orderedTabsWithEditors,
|
||||
closingTabIds,
|
||||
activeTabId: activeBeforeClose,
|
||||
});
|
||||
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
|
||||
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id));
|
||||
const canClose = !regularIds.length || await closeTabsBatchImpl(
|
||||
() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds: regularIds, workspaces }),
|
||||
regularIds,
|
||||
);
|
||||
if (!canClose) return;
|
||||
for (const id of pluginIds) pluginViewTabStore.close(id);
|
||||
if (closingTabIds.has(activeBeforeClose)) activeTabStore.setActiveTabId(focusAfterClose);
|
||||
},
|
||||
(targetIds: string[]) => closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
|
||||
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
|
||||
activeTabStore, editorTabStore, pluginViewTabStore,
|
||||
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
|
||||
}),
|
||||
targetIds,
|
||||
),
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView, orderedTabsWithEditors],
|
||||
);
|
||||
|
||||
@@ -1078,6 +1080,8 @@ export function AppSideEffects() {
|
||||
terminalPaneMagnificationRef,
|
||||
toEditorTabId,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWorkspaceViewMode,
|
||||
@@ -1092,6 +1096,7 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
moveFocusInWorkspace,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
confirmIfBusyLocalTerminal,
|
||||
]);
|
||||
@@ -1751,6 +1756,14 @@ export function AppSideEffects() {
|
||||
updateHosts(vaultHosts);
|
||||
}, [ephemeralHostIds, updateHosts]);
|
||||
|
||||
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
if (ephemeralHostIds.has(hostId)) {
|
||||
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
|
||||
return;
|
||||
}
|
||||
updateHostDistro(hostId, distro);
|
||||
}, [ephemeralHostIds, updateHostDistro]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1886,6 +1899,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
runSnippet: handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
toggleScriptsSidePanelRef,
|
||||
@@ -1962,6 +1976,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
handleEndSessionDrag,
|
||||
|
||||
@@ -300,6 +300,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
localShellSidePanelAutoOpen,
|
||||
localShellSidePanelAutoOpenTab,
|
||||
} = useSettingsChromeStore();
|
||||
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||
|
||||
@@ -504,6 +506,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
@@ -751,6 +754,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
|
||||
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TransferOwnerObservation } from "../state/sftp/waitForTransferOwner";
|
||||
import type { TransferStatus, TransferTask } from "../../domain/models";
|
||||
|
||||
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
|
||||
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
|
||||
|
||||
export interface DedicatedResumeChildUpdateBatcher {
|
||||
push(task: TransferTask): void;
|
||||
push(task: TransferTask, observation?: TransferOwnerObservation): void;
|
||||
discard(taskId: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
@@ -24,25 +26,39 @@ export function createDedicatedResumeChildUpdateBatcher(deps: {
|
||||
hasTask: (taskId: string) => boolean;
|
||||
upsertTasks: (tasks: readonly TransferTask[]) => void;
|
||||
}): DedicatedResumeChildUpdateBatcher {
|
||||
const pending = new Map<string, TransferTask>();
|
||||
const pending = new Map<string, { task: TransferTask; observation?: TransferOwnerObservation }>();
|
||||
const flush = () => {
|
||||
if (pending.size === 0) return;
|
||||
const batch = [...pending.values()];
|
||||
pending.clear();
|
||||
deps.upsertTasks(batch);
|
||||
try {
|
||||
const updates = batch.filter(({ observation }) => !observation?.hasIdentityConflict()).map(({ task }) => task);
|
||||
if (updates.length) deps.upsertTasks(updates);
|
||||
} finally {
|
||||
for (const { observation } of batch) observation?.dispose();
|
||||
}
|
||||
};
|
||||
return {
|
||||
push(task) {
|
||||
push(task, observation) {
|
||||
if (observation?.hasIdentityConflict()) {
|
||||
pending.get(task.id)?.observation?.dispose();
|
||||
pending.delete(task.id);
|
||||
observation.dispose();
|
||||
return;
|
||||
}
|
||||
const shouldBatch = !!task.parentTaskId
|
||||
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
|
||||
&& deps.hasTask(task.id);
|
||||
if (!shouldBatch) {
|
||||
deps.upsertTasks([task]);
|
||||
try { deps.upsertTasks([task]); } finally { observation?.dispose(); }
|
||||
return;
|
||||
}
|
||||
pending.set(task.id, task);
|
||||
const previous = pending.get(task.id);
|
||||
if (previous?.observation && previous.observation !== observation) previous.observation.dispose();
|
||||
pending.set(task.id, { task, observation });
|
||||
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
|
||||
},
|
||||
discard: (taskId) => { pending.get(taskId)?.observation?.dispose(); pending.delete(taskId); },
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
@@ -37,7 +36,6 @@ export function TerminalHost() {
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const vaultActions = useVaultSnapshotActions();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const terminalSettingsActions = useTerminalSettingsActions();
|
||||
const {
|
||||
@@ -247,7 +245,7 @@ export function TerminalHost() {
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: vaultActions?.updateHostDistro,
|
||||
updateHostDistro: handlers.updateTerminalHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||
@@ -275,7 +273,6 @@ export function TerminalHost() {
|
||||
terminalHosts,
|
||||
terminalSettings,
|
||||
terminalSettingsActions,
|
||||
vaultActions,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -181,11 +181,14 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
},
|
||||
{
|
||||
children,
|
||||
onChildUpdate: (child) => {
|
||||
onChildUpdate: (child, observation) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
|
||||
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" }, observation);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onChildSuperseded: (taskId) => childUpdateBatcher.discard(taskId),
|
||||
flushChildUpdates: () => childUpdateBatcher.flush(),
|
||||
onDirectoryCheckpointUpdate: (checkpoint) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
sftpTransferCenterStore.patchTask(task.id, {
|
||||
|
||||
@@ -136,7 +136,7 @@ test('initialization applies the protected preview before persisting and enablin
|
||||
return liveLocalPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
preparePayloadApply: async (incoming) => async () => {
|
||||
calls.push('apply');
|
||||
assert.equal(incoming.convergentSync?.schemaVersion, 2);
|
||||
},
|
||||
@@ -212,7 +212,7 @@ test('initialization applies a concurrent provider merge before releasing the mi
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => currentPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: async (incoming) => {
|
||||
preparePayloadApply: async (incoming) => async () => {
|
||||
assert.equal(lockHeld, true);
|
||||
applied.push(incoming);
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
@@ -265,7 +265,7 @@ test('initialization rejects a stale preview before backup or apply', async () =
|
||||
buildCurrentPayload: () => changedPayload,
|
||||
buildPreApplyPayload: () => changedPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
preparePayloadApply: async () => async () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async (options) => {
|
||||
@@ -300,7 +300,7 @@ test('blocked previews cannot enter the protected initialization transaction', a
|
||||
buildCurrentPayload: () => localPayload,
|
||||
buildPreApplyPayload: () => localPayload,
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {},
|
||||
preparePayloadApply: async () => async () => {},
|
||||
runProtectedApply: async () => {
|
||||
entered = true;
|
||||
},
|
||||
@@ -337,7 +337,7 @@ test('a locked manager cannot enter the protected initialization transaction', a
|
||||
return localPayload;
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
applyPayload: () => {
|
||||
preparePayloadApply: async () => async () => {
|
||||
applied = true;
|
||||
},
|
||||
runProtectedApply: async () => {
|
||||
@@ -352,3 +352,56 @@ test('a locked manager cannot enter the protected initialization transaction', a
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
|
||||
});
|
||||
|
||||
for (const failDuringPublish of [false, true]) {
|
||||
test(`migration header failure precedes protected writes (${failDuringPublish ? 'publish' : 'initialize'})`, async () => {
|
||||
const { prepareSyncPayloadApply } = await import('./syncPayload.ts');
|
||||
const { readInterruptedVaultApply } = await import('./localVaultBackups.ts');
|
||||
const headers = { ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] } };
|
||||
const localPayload: SyncPayload = { ...payload(), ...(failDuringPublish ? {} : { settings: headers }) };
|
||||
let currentPayload = localPayload;
|
||||
let imports = 0;
|
||||
let replicaCommits = 0;
|
||||
let snapshots = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => {
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
throw new Error('keychain locked');
|
||||
} },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const plan = planConvergentSyncMigration({
|
||||
localPayload, localTrustedBaseline: null, providers: [], deviceId: 'device-a', now: NOW,
|
||||
});
|
||||
const manager = {
|
||||
isUnlocked: () => true,
|
||||
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
|
||||
saveConvergentReplica: async () => { replicaCommits++; },
|
||||
saveConvergentProviderBaseline: async () => {},
|
||||
syncConvergentProvidersUnderLock: async (
|
||||
_incoming: SyncPayload,
|
||||
apply: (incoming: SyncPayload, commit: () => Promise<void>) => Promise<void>,
|
||||
) => {
|
||||
await apply({ ...currentPayload, settings: headers }, async () => { replicaCommits++; });
|
||||
return new Map();
|
||||
},
|
||||
} as unknown as CloudSyncManager;
|
||||
await assert.rejects(initializePreparedConvergentMigration({
|
||||
prepared: { plan, providerBaselines: [], localSnapshot: localPayload }, manager, now: NOW,
|
||||
buildCurrentPayload: () => currentPayload,
|
||||
buildPreApplyPayload: () => { snapshots++; return payload(); },
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
preparePayloadApply: async (incoming) => {
|
||||
const apply = await prepareSyncPayloadApply(incoming, { importVaultData: () => { imports++; } });
|
||||
return async () => {
|
||||
await apply();
|
||||
currentPayload = stripConvergentSyncEnvelope(incoming);
|
||||
};
|
||||
},
|
||||
}));
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
assert.equal(imports, failDuringPublish ? 1 : 0);
|
||||
assert.equal(replicaCommits, failDuringPublish ? 1 : 0);
|
||||
assert.equal(snapshots, failDuringPublish ? 1 : 0);
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
prepared: PreparedConvergentMigration;
|
||||
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
preparePayloadApply: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
manager?: CloudSyncManager;
|
||||
now?: number;
|
||||
@@ -164,8 +164,9 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
|
||||
);
|
||||
}
|
||||
const applyPreparedPayload = await options.preparePayloadApply(prepared.plan.payload as SyncPayload);
|
||||
return async () => {
|
||||
await options.applyPayload(prepared.plan.payload as SyncPayload);
|
||||
await applyPreparedPayload();
|
||||
for (const baseline of prepared.providerBaselines) {
|
||||
await manager.saveConvergentProviderBaseline(baseline);
|
||||
}
|
||||
@@ -195,8 +196,9 @@ export async function initializePreparedConvergentMigration(options: {
|
||||
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
|
||||
);
|
||||
}
|
||||
const applyPreparedPayload = await options.preparePayloadApply(mergedPayload);
|
||||
return async () => {
|
||||
await options.applyPayload(mergedPayload);
|
||||
await applyPreparedPayload();
|
||||
await commitReplica();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4,10 +4,12 @@ export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Context compacted',
|
||||
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent activity',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -53,6 +55,15 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.headers': 'Custom HTTP Headers',
|
||||
'ai.providers.headers.help': 'Optional headers for this provider. Values are hidden and encrypted when saved.',
|
||||
'ai.providers.headers.name': 'Header name',
|
||||
'ai.providers.headers.value': 'Header value',
|
||||
'ai.providers.headers.add': 'Add header',
|
||||
'ai.providers.headers.remove': 'Remove header',
|
||||
'ai.providers.headers.invalid': 'Use valid, unique HTTP headers. Content-Length and Transfer-Encoding are managed automatically.',
|
||||
'ai.providers.headers.loadError': 'Unable to unlock saved headers. Cancel and try again after unlocking your system keychain.',
|
||||
'ai.providers.headers.saveError': 'Unable to save securely. Check that your system keychain is available and try again.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
@@ -225,20 +236,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Reasoning Effort',
|
||||
'ai.codebuddy.effort.default': 'Default',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
|
||||
'ai.codebuddy.maxTurns': 'Max Turns',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
|
||||
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
|
||||
'ai.codebuddy.sandbox': 'Sandbox Mode',
|
||||
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
|
||||
'ai.codebuddy.persistSession': 'Session Persistence',
|
||||
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
|
||||
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
|
||||
@@ -273,6 +288,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': "Xiaomi's MiMo Code CLI, an OpenCode fork. Install it and configure providers in its own config, then select it as an external coding agent.",
|
||||
'ai.mimo.detecting': 'Detecting...',
|
||||
'ai.mimo.detected': 'Detected',
|
||||
'ai.mimo.notFound': 'Not found',
|
||||
'ai.mimo.path': 'Path:',
|
||||
'ai.mimo.notFoundHint': 'Could not find mimo in PATH. Install it or specify the executable path below.',
|
||||
'ai.mimo.customPathPlaceholder': 'e.g. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Check',
|
||||
'ai.mimo.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
@@ -350,21 +377,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Other',
|
||||
'ai.externalMcp.cliCommand': 'CLI command',
|
||||
'ai.externalMcp.configSnippet': 'Config snippet',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Universal setup prompt',
|
||||
'ai.externalMcp.addToCodex': 'Add to Codex',
|
||||
'ai.externalMcp.addToClaude': 'Add to Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Add to Grok',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry and Netcatty skill added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry and Netcatty skill added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry and Netcatty skill added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
|
||||
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
|
||||
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
|
||||
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
|
||||
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / other clients',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your Cursor MCP configuration without replacing existing servers.',
|
||||
'ai.externalMcp.other.description': 'Copy the setup prompt into another terminal agent, or merge the MCP snippet into its client configuration.',
|
||||
'ai.externalMcp.status.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.status.disabled': 'Disabled',
|
||||
'ai.externalMcp.status.running': 'Running',
|
||||
@@ -471,6 +501,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Use "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Thinking',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.thinkingDefault': 'Provider default',
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
@@ -497,6 +528,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
@@ -575,8 +613,16 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.movePanelBottom': 'Move panel to bottom',
|
||||
'terminal.layer.choosePanelPosition': 'Change position',
|
||||
'terminal.layer.dockLeft': 'Left',
|
||||
'terminal.layer.dockRight': 'Right',
|
||||
'terminal.layer.dockBottom': 'Bottom',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
|
||||
'terminal.layer.layoutSaveFailed': 'Could not save layout',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
|
||||
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'sort.ip': 'By IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
@@ -77,7 +78,7 @@ export const enCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'placeholder.searchHosts': 'Search hosts or tags...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
@@ -537,7 +538,7 @@ Highlight the focused split pane:
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Bright',
|
||||
'settings.appearance.appIcon.dark': 'Dark',
|
||||
@@ -613,6 +614,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.ligatures': 'Font ligatures',
|
||||
'settings.terminal.font.ligatures.desc': 'Render ligatures such as != when the terminal font includes them',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -620,6 +623,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.cursor.style.block': 'Block',
|
||||
'settings.terminal.cursor.style.bar': 'Bar',
|
||||
'settings.terminal.cursor.style.underline': 'Underline',
|
||||
'settings.terminal.cursor.barWidth': 'Bar cursor width',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Width of bar cursors used by applications such as vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Cursor blink',
|
||||
'settings.terminal.cursor.highlightLine': 'Highlight current line',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -645,10 +650,18 @@ Highlight the focused split pane:
|
||||
'settings.terminal.sidePanel.pane.system': 'System',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notes',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': 'Local Shell Side Panel',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Open side panel with local shell',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Automatically open a side panel when opening a local shell.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Default side panel',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Files',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Right-click hold opens menu',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'When right-click pastes or selects a word, hold for half a second to open the menu. Does not override terminal apps that capture the mouse.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
|
||||
@@ -685,12 +698,21 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Ask before pasting multiple lines',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Show a confirmation dialog before pasting multiple lines, with the line/character count, an editable preview, and Send / Send line by line / Cancel actions. Protects switch and firewall CLIs (Cisco IOS, Huawei VRP, H3C Comware) that do not support bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Confirmation threshold (lines)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Ask for confirmation when a paste contains at least this many lines.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
@@ -706,6 +728,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Tab double-click behavior',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Choose the action when double-clicking a terminal tab.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicate session (new connection)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Copy tab (reuse connection)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'Do nothing',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
|
||||
@@ -1037,6 +1064,10 @@ Highlight the focused split pane:
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'This group contains hosts imported from a linked SSH config file. Every host under its managed subgroup will be deleted, even hosts not managed by that file. All other hosts will move to the root level unless you also delete them below.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'Deleting this group will also clear its entries from the linked SSH config file. Entries that came from that file may be lost for other apps that use it. Back up the file before continuing.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'The managed files affected by this deletion have changed. Review the files and confirm again.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Linked file: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Delete Selected Groups',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'This will permanently delete the selected groups and move their hosts to the root level.',
|
||||
@@ -1095,6 +1126,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '{count} saved key passphrases could not be read and were left blank',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '{count} saved proxy passwords could not be read and were left blank',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const enScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
'scripts.actions.runOnAllTabs': 'Run on all tabs',
|
||||
'scripts.actions.copyCommand': 'Copy command',
|
||||
'scripts.actions.copyCommand.copied': 'Command copied',
|
||||
'scripts.actions.copyCommand.failed': 'Could not copy command',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
'terminal.pasteConfirm.title': 'Paste multiple lines?',
|
||||
'terminal.pasteConfirm.summary': '{lines} lines ({chars} characters) will be sent to the session.',
|
||||
'terminal.pasteConfirm.preview': 'Preview (editable)',
|
||||
'terminal.pasteConfirm.send': 'Send',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Send line by line',
|
||||
'terminal.pasteConfirm.cancel': 'Cancel',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
@@ -91,6 +100,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
@@ -178,6 +188,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
@@ -251,6 +262,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Intense foreground (bold)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
@@ -637,6 +649,7 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToLeft': 'Close Tabs to the Left',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -807,6 +820,9 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.backspaceBehavior': 'Backspace key',
|
||||
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
|
||||
'serial.encoding.pendingInput': 'Submit or clear the current input before changing the encoding.',
|
||||
'serial.field.byteOrientedBackspace': 'Byte-Oriented Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Send one DEL per byte so the device deletes a whole multi-byte character (for devices that delete bytes). Disable for character-aware line editors (e.g. Linux readline).',
|
||||
'serial.backspace.default': 'Default (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Charset',
|
||||
@@ -820,6 +836,10 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
'serial.field.username': 'Username',
|
||||
'serial.field.password': 'Password',
|
||||
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',
|
||||
'serial.field.clearPassword': 'Clear saved password',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
|
||||
@@ -116,6 +116,11 @@ export const enVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Import every session and keep the folder structure.',
|
||||
'vault.import.securecrt.file': 'Single session file',
|
||||
'vault.import.securecrt.fileDesc': 'Import one SecureCRT .ini session.',
|
||||
'vault.import.finalshell.promptTitle': 'Import from FinalShell',
|
||||
'vault.import.finalshell.folder': 'conn folder',
|
||||
'vault.import.finalshell.folderDesc': 'Import every JSON connection and keep the folder structure.',
|
||||
'vault.import.finalshell.file': 'Single connection file',
|
||||
'vault.import.finalshell.fileDesc': 'Import one FinalShell JSON connection.',
|
||||
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
|
||||
'vault.import.csv.downloadTemplate': 'Download CSV template',
|
||||
'vault.import.toast.start': 'Importing from {format}...',
|
||||
@@ -261,6 +266,8 @@ export const enVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
'pf.form.autoReconnect': 'Auto Reconnect',
|
||||
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
@@ -710,8 +717,13 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'Operating System',
|
||||
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
|
||||
'hostDetails.os.title': 'System identification',
|
||||
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
|
||||
'hostDetails.os.auto': 'Automatic',
|
||||
'hostDetails.os.unknown': 'Other / unknown',
|
||||
'hostDetails.os.selection': 'System selection',
|
||||
'hostDetails.os.current': 'Current system',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -4,10 +4,12 @@ export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Contexto compactado',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -225,20 +227,24 @@ export const esAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.minimal': 'Mínimo',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.effort.max': 'Máximo',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.persistSession': 'Persistencia de sesión',
|
||||
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
@@ -273,6 +279,18 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': 'CLI de MiMo Code de Xiaomi, un fork de OpenCode. Instálalo y configura los proveedores en su propia configuración, luego selecciónalo como agente de codificación externo.',
|
||||
'ai.mimo.detecting': 'Detectando...',
|
||||
'ai.mimo.detected': 'Detectado',
|
||||
'ai.mimo.notFound': 'No encontrado',
|
||||
'ai.mimo.path': 'Ruta:',
|
||||
'ai.mimo.notFoundHint': 'No se pudo encontrar mimo en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.mimo.customPathPlaceholder': 'p. ej. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Verificar',
|
||||
'ai.mimo.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
@@ -350,21 +368,24 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Otros',
|
||||
'ai.externalMcp.cliCommand': 'Comando CLI',
|
||||
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Prompt de instalación universal',
|
||||
'ai.externalMcp.addToCodex': 'Agregar a Codex',
|
||||
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Agregar a Grok',
|
||||
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.codexAdded': 'Entrada MCP de Codex y skill de Netcatty agregados. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada MCP de Claude Code y skill de Netcatty agregados. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada MCP de Grok y skill de Netcatty agregados. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
|
||||
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto con la configuración MCP de Cursor sin reemplazar los servidores existentes.',
|
||||
'ai.externalMcp.other.description': 'Copia el prompt de instalación en otro agente de terminal o combina el fragmento MCP con la configuración de su cliente.',
|
||||
'ai.externalMcp.status.unavailable': 'No disponible',
|
||||
'ai.externalMcp.status.disabled': 'Desactivado',
|
||||
'ai.externalMcp.status.running': 'En ejecución',
|
||||
@@ -471,6 +492,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Usar "{id}"',
|
||||
'ai.chat.thinkingLevel': 'Razonamiento',
|
||||
'ai.chat.thinkingOff': 'Off',
|
||||
'ai.chat.thinkingDefault': 'Predeterminado del proveedor',
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
@@ -497,6 +519,13 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
@@ -575,6 +604,11 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
|
||||
'terminal.layer.choosePanelPosition': 'Cambiar posición',
|
||||
'terminal.layer.dockLeft': 'Izquierda',
|
||||
'terminal.layer.dockRight': 'Derecha',
|
||||
'terminal.layer.dockBottom': 'Abajo',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Más reciente a más antiguo',
|
||||
'sort.oldest': 'Más antiguo a más reciente',
|
||||
'sort.ip': 'Por IP',
|
||||
'sort.group': 'Por grupo',
|
||||
'field.label': 'Etiqueta',
|
||||
'field.type': 'Tipo',
|
||||
@@ -77,7 +78,7 @@ export const esCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Seleccionar hosts',
|
||||
'placeholder.workspaceName': 'Nombre del espacio de trabajo',
|
||||
'placeholder.sessionName': 'Nombre de la sesión',
|
||||
'placeholder.searchHosts': 'Buscar hosts...',
|
||||
'placeholder.searchHosts': 'Buscar hosts o etiquetas...',
|
||||
'toast.settingsUnavailable': 'La ventana de configuración no está disponible en esta plataforma.',
|
||||
'credentials.protectionUnavailable.title': 'Protección de credenciales no disponible',
|
||||
'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.',
|
||||
@@ -452,7 +453,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Brillante',
|
||||
'settings.appearance.appIcon.dark': 'Oscuro',
|
||||
@@ -528,6 +529,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.font.weightBold.desc': 'Peso del texto en negrita (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Suavizado de fuente',
|
||||
'settings.terminal.font.smoothing.desc': 'Usar el anti-aliasing nativo de fuentes de macOS',
|
||||
'settings.terminal.font.ligatures': 'Ligaduras de fuente',
|
||||
'settings.terminal.font.ligatures.desc': 'Mostrar ligaduras como != cuando la fuente del terminal las incluye',
|
||||
'settings.terminal.font.linePadding': 'Espaciado de líneas',
|
||||
'settings.terminal.font.linePadding.desc': 'Espacio adicional entre líneas (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Tipo de emulación de terminal',
|
||||
@@ -535,6 +538,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.cursor.style.block': 'Bloque',
|
||||
'settings.terminal.cursor.style.bar': 'Barra',
|
||||
'settings.terminal.cursor.style.underline': 'Subrayado',
|
||||
'settings.terminal.cursor.barWidth': 'Ancho del cursor de barra',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ancho de los cursores de barra utilizados por aplicaciones como vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Parpadeo del cursor',
|
||||
'settings.terminal.cursor.highlightLine': 'Resaltar línea actual',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -560,10 +565,18 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.sidePanel.pane.system': 'Sistema',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notas',
|
||||
'settings.terminal.sidePanel.pane.ai': 'IA',
|
||||
'settings.terminal.section.localShellSidePanel': 'Panel lateral de Shell local',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Expandir panel lateral al abrir Shell local',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Abre automáticamente un panel lateral después de abrir el Shell local.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Panel lateral predeterminado',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Elige qué panel se abre primero.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Archivos',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
|
||||
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Acción al hacer clic derecho en la terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Mantener clic derecho abre el menú',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Si el clic derecho pega o selecciona una palabra, mantén pulsado medio segundo para abrir el menú. No afecta a las aplicaciones que capturan el ratón.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Mostrar menú sobre aplicaciones en pantalla completa',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Muestra el menú contextual incluso cuando tmux, vim u otras aplicaciones en pantalla completa toman el control del mouse. Cuando está desactivado, el clic derecho se pasa a la aplicación (Shift+Clic derecho sigue abriendo el menú).',
|
||||
@@ -600,12 +613,21 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Subir automáticamente imágenes del portapapeles al pegar',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Cuando el portapapeles contiene una imagen, pegarla en una sesión remota la sube por SFTP a .netcatty-paste-images/ dentro del directorio de trabajo remoto e inserta la ruta remota en lugar de pegar texto.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Preguntar antes de pegar varias líneas',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Muestra un diálogo de confirmación antes de pegar varias líneas, con el número de líneas y caracteres, una vista previa editable y las acciones Enviar / Enviar línea por línea / Cancelar. Protege las CLI de dispositivos de red (Cisco IOS, Huawei VRP, H3C Comware) que no admiten bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Umbral de confirmación (líneas)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Pedir confirmación cuando el texto pegado contenga al menos esta cantidad de líneas.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Texto de Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` borra el historial de desplazamiento',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Haz que `clear` también borre el buffer del historial de desplazamiento (predeterminado de POSIX). Desactívalo para mantener visible el historial después de `clear`.',
|
||||
@@ -621,6 +643,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Comportamiento de doble clic en pestaña',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Elige la acción al hacer doble clic en una pestaña de terminal.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicar sesión (nueva conexión)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Copiar pestaña (reutilizar conexión)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'No hacer nada',
|
||||
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
|
||||
@@ -995,6 +1022,10 @@ Resalta el panel dividido enfocado:
|
||||
'vault.groups.deleteDialogTitle': 'Eliminar grupo',
|
||||
'vault.groups.deleteDialog.desc': 'Esto eliminará permanentemente el grupo y moverá todos los hosts al nivel raíz.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'Este es un grupo administrado de configuración SSH. Eliminarlo también eliminará todos los hosts y lo desvinculará del archivo de origen.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'Este grupo contiene hosts importados de un archivo de configuración SSH vinculado. Todos los hosts bajo su subgrupo administrado se eliminarán, incluso los hosts no administrados por ese archivo. Los demás hosts se moverán al nivel raíz a menos que también los elimine abajo.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'Al eliminar este grupo también se borrarán sus entradas del archivo de configuración SSH vinculado. Las entradas provenientes de ese archivo pueden perderse y también afectará a otras aplicaciones que lo usen. Haz una copia de seguridad del archivo antes de continuar.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'Los archivos administrados afectados por esta eliminación han cambiado. Revisa los archivos y vuelve a confirmar.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Archivo vinculado: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'También eliminar todos los hosts de este grupo',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Eliminar grupos seleccionados',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'Esto eliminará permanentemente los grupos seleccionados y moverá sus hosts al nivel raíz.',
|
||||
@@ -1053,6 +1084,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.export.toast.success': 'Se exportaron {count} hosts a CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Se exportaron {count} hosts a CSV (se omitieron {skipped} hosts no compatibles)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'No se pudieron leer {count} frases de contraseña de claves guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'No se pudieron leer {count} contraseñas de proxy guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.noHosts': 'No hay hosts para exportar',
|
||||
'vault.hosts.allHosts': 'Todos los hosts',
|
||||
'vault.hosts.pinned': 'Fijados',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const esScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.copyCommand': 'Copiar comando',
|
||||
'scripts.actions.copyCommand.copied': 'Comando copiado',
|
||||
'scripts.actions.copyCommand.failed': 'No se pudo copiar el comando',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'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.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.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'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)
|
||||
'terminal.pasteConfirm.title': '¿Pegar varias líneas?',
|
||||
'terminal.pasteConfirm.summary': 'Se enviarán {lines} líneas ({chars} caracteres) a la sesión.',
|
||||
'terminal.pasteConfirm.preview': 'Vista previa (editable)',
|
||||
'terminal.pasteConfirm.send': 'Enviar',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Enviar línea por línea',
|
||||
'terminal.pasteConfirm.cancel': 'Cancelar',
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
@@ -91,6 +100,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.gpu': 'Uso de GPU',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
@@ -178,6 +188,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
@@ -251,6 +262,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Primer plano intenso (negrita)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
@@ -637,6 +649,7 @@ export const esTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
'tabs.closeToLeft': 'Cerrar pestañas a la izquierda',
|
||||
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
|
||||
'tabs.closeAll': 'Cerrar todas',
|
||||
'keychain.edit.labelRequired': 'Etiqueta *',
|
||||
@@ -807,6 +820,9 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.encoding.pendingInput': 'Envía o borra la entrada actual antes de cambiar la codificación.',
|
||||
'serial.field.byteOrientedBackspace': 'Retroceso orientado a bytes',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Envía un DEL por byte para que el dispositivo elimine un carácter multibyte completo (para dispositivos que eliminan por bytes). Desactívelo para editores de línea conscientes de caracteres (p. ej. readline de Linux).',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
@@ -820,6 +836,10 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
|
||||
'serial.connectAndSave': 'Conectar y guardar',
|
||||
'serial.edit.title': 'Configuración del puerto serial',
|
||||
'serial.field.username': 'Usuario',
|
||||
'serial.field.password': 'Contraseña',
|
||||
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',
|
||||
'serial.field.clearPassword': 'Borrar la contraseña guardada',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Se requiere autenticación',
|
||||
|
||||
@@ -116,6 +116,11 @@ export const esVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
|
||||
'vault.import.securecrt.file': 'Archivo de sesión único',
|
||||
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
|
||||
'vault.import.finalshell.promptTitle': 'Importar desde FinalShell',
|
||||
'vault.import.finalshell.folder': 'Carpeta conn',
|
||||
'vault.import.finalshell.folderDesc': 'Importa todas las conexiones JSON y conserva la estructura de carpetas.',
|
||||
'vault.import.finalshell.file': 'Archivo de conexión único',
|
||||
'vault.import.finalshell.fileDesc': 'Importa una conexión JSON de FinalShell.',
|
||||
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
|
||||
'vault.import.toast.start': 'Importando desde {format}...',
|
||||
@@ -261,6 +266,8 @@ export const esVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Detener y eliminar',
|
||||
'pf.form.autoStart': 'Inicio automático',
|
||||
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
|
||||
'pf.form.autoReconnect': 'Reconexión automática',
|
||||
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Nueva carpeta',
|
||||
@@ -710,8 +717,13 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'Sistema operativo',
|
||||
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
|
||||
'hostDetails.os.title': 'Identificación del sistema',
|
||||
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
|
||||
'hostDetails.os.auto': 'Automático',
|
||||
'hostDetails.os.unknown': 'Otro / desconocido',
|
||||
'hostDetails.os.selection': 'Selección del sistema',
|
||||
'hostDetails.os.current': 'Sistema actual',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
@@ -206,6 +208,26 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
'ai.codebuddy.advancedSection': 'Расширенные параметры (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Усилие рассуждения',
|
||||
'ai.codebuddy.effort.default': 'По умолчанию',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': 'Управляет глубиной рассуждения модели. Для простых команд используйте Minimal/Low, чтобы экономить токены, а для сложной диагностики — Max/High.',
|
||||
'ai.codebuddy.maxTurns': 'Максимум шагов',
|
||||
'ai.codebuddy.maxTurns.hint': 'Ограничивает максимальное число шагов диалога за запрос, предотвращая зацикливание. Оставьте пустым для значения по умолчанию.',
|
||||
'ai.codebuddy.maxBudget': 'Максимальный бюджет (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Максимальные расходы (USD) за запрос. При превышении выполнение останавливается автоматически. Оставьте пустым, чтобы не ограничивать.',
|
||||
'ai.codebuddy.sandbox': 'Режим песочницы',
|
||||
'ai.codebuddy.sandbox.hint': 'Выполняет вызовы инструментов в песочнице, ограничивая доступ к файловой системе и сети.',
|
||||
'ai.codebuddy.persistSession': 'Сохранение сессии',
|
||||
'ai.codebuddy.persistSession.hint': 'Сохраняет на диск собственную историю сессии CodeBuddy, чтобы CodeBuddy мог возобновить её позже. Отключите, чтобы запретить хранение сессии CodeBuddy; Netcatty всё ещё может сохранять историю чата и метаданные внешней сессии. Контрольные точки файлов пропускаются. Требуется CodeBuddy CLI 2.125.1 или новее.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Контрольные точки файлов',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Включает контрольные точки файловых операций, чтобы изменения файлов ИИ можно было откатить.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
|
||||
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
|
||||
'ai.codebuddy.elicitation.select': 'Выберите вариант',
|
||||
@@ -302,21 +324,24 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Другие',
|
||||
'ai.externalMcp.cliCommand': 'CLI-команда',
|
||||
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Универсальный запрос установки',
|
||||
'ai.externalMcp.addToCodex': 'Добавить в Codex',
|
||||
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Добавить в Grok',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex и навык Netcatty добавлены. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code и навык Netcatty добавлены. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok и навык Netcatty добавлены. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
|
||||
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
|
||||
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
|
||||
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Добавьте этот фрагмент в конфигурацию MCP Cursor, сохранив существующие серверы.',
|
||||
'ai.externalMcp.other.description': 'Скопируйте запрос установки в другой терминальный агент или добавьте фрагмент MCP в конфигурацию его клиента.',
|
||||
'ai.externalMcp.status.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.status.disabled': 'Выключено',
|
||||
'ai.externalMcp.status.running': 'Работает',
|
||||
@@ -423,6 +448,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': 'Использовать «{id}»',
|
||||
'ai.chat.thinkingLevel': 'Мышление',
|
||||
'ai.chat.thinkingOff': 'Выкл.',
|
||||
'ai.chat.thinkingDefault': 'По умолчанию провайдера',
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
@@ -449,6 +475,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
@@ -527,6 +560,11 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
|
||||
'terminal.layer.choosePanelPosition': 'Изменить положение',
|
||||
'terminal.layer.dockLeft': 'Слева',
|
||||
'terminal.layer.dockRight': 'Справа',
|
||||
'terminal.layer.dockBottom': 'Снизу',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sort.newest': 'Сначала новые',
|
||||
'sort.oldest': 'Сначала старые',
|
||||
'sort.group': 'По группе',
|
||||
'sort.ip': 'По IP',
|
||||
'field.label': 'Метка',
|
||||
'field.type': 'Тип',
|
||||
'auth.keyType': 'Тип {type}',
|
||||
@@ -76,7 +77,7 @@ export const ruCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Выбрать хосты',
|
||||
'placeholder.workspaceName': 'Имя рабочего пространства',
|
||||
'placeholder.sessionName': 'Имя сессии',
|
||||
'placeholder.searchHosts': 'Поиск хостов...',
|
||||
'placeholder.searchHosts': 'Поиск хостов или тегов...',
|
||||
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
|
||||
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
@@ -506,7 +507,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
'settings.appearance.appIcon.bright': 'Яркий',
|
||||
'settings.appearance.appIcon.dark': 'Тёмный',
|
||||
@@ -581,6 +582,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
|
||||
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
|
||||
'settings.terminal.font.ligatures': 'Лигатуры шрифта',
|
||||
'settings.terminal.font.ligatures.desc': 'Показывать лигатуры вроде !=, если шрифт терминала их содержит',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
@@ -588,6 +591,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': 'Блок',
|
||||
'settings.terminal.cursor.style.bar': 'Полоса',
|
||||
'settings.terminal.cursor.style.underline': 'Подчёркивание',
|
||||
'settings.terminal.cursor.barWidth': 'Толщина курсора-полосы',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ширина курсоров-полос, используемых приложениями вроде vi (1-4 пикс.)',
|
||||
'settings.terminal.cursor.blink': 'Мигание курсора',
|
||||
'settings.terminal.cursor.highlightLine': 'Подсветка текущей строки',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -606,6 +611,8 @@ export const ruCoreMessages: Messages = {
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Удержание правой кнопки открывает меню',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Если правая кнопка вставляет текст или выделяет слово, удерживайте её полсекунды для открытия меню. Не влияет на приложения, захватывающие мышь.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
|
||||
@@ -642,12 +649,21 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Спрашивать перед вставкой нескольких строк',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Перед вставкой нескольких строк показывать диалог подтверждения с количеством строк и символов, редактируемым предпросмотром и действиями «Отправить» / «Отправить построчно» / «Отмена». Защищает CLI сетевых устройств (Cisco IOS, Huawei VRP, H3C Comware), не поддерживающих bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Порог подтверждения (строк)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Запрашивать подтверждение, если вставка содержит не меньше этого числа строк.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
@@ -663,6 +679,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
|
||||
'settings.terminal.behavior.tabDoubleClick': 'Действие по двойному щелчку вкладки',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': 'Выберите действие при двойном щелчке вкладки терминала.',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Дублировать сеанс (новое подключение)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': 'Копировать вкладку (использовать подключение)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': 'Ничего не делать',
|
||||
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
@@ -739,6 +760,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
|
||||
'settings.terminal.section.localShellSidePanel': 'Боковая панель локальной оболочки',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Развернуть боковую панель при открытии локальной оболочки',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Автоматически открывать боковую панель после открытия локальной оболочки.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Боковая панель по умолчанию',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
@@ -1019,6 +1046,10 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': 'Удалить группу',
|
||||
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'Эта группа содержит хосты, импортированные из связанного SSH-конфига. Все хосты под управляемой подгруппой будут удалены, включая хосты, не управляемые этим файлом. Остальные хосты будут перемещены в корень, если вы также не удалите их ниже.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'При удалении этой группы её записи также будут удалены из связанного файла конфигурации SSH. Записи, импортированные из этого файла, могут быть утеряны, это затронет и другие приложения, использующие файл. Сделайте резервную копию файла перед продолжением.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'Управляемые файлы, затрагиваемые этим удалением, изменились. Проверьте файлы и подтвердите удаление снова.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Связанный файл: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Удалить выбранные группы',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'Выбранные группы будут безвозвратно удалены, а их хосты перемещены в корень.',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const ruScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
|
||||
'scripts.actions.copyCommand': 'Копировать команду',
|
||||
'scripts.actions.copyCommand.copied': 'Команда скопирована',
|
||||
'scripts.actions.copyCommand.failed': 'Не удалось скопировать команду',
|
||||
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
|
||||
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
|
||||
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
'terminal.pasteConfirm.title': 'Вставить несколько строк?',
|
||||
'terminal.pasteConfirm.summary': 'В сессию будет отправлено строк: {lines} (символов: {chars}).',
|
||||
'terminal.pasteConfirm.preview': 'Предпросмотр (можно изменить)',
|
||||
'terminal.pasteConfirm.send': 'Отправить',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Отправить построчно',
|
||||
'terminal.pasteConfirm.cancel': 'Отмена',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
@@ -112,6 +121,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
@@ -199,6 +209,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
@@ -269,6 +280,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Яркие цвета',
|
||||
'terminal.customTheme.color.background': 'Фон',
|
||||
'terminal.customTheme.color.foreground': 'Текст',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Яркий текст (жирный)',
|
||||
'terminal.customTheme.color.cursor': 'Курсор',
|
||||
'terminal.customTheme.color.selection': 'Выделение',
|
||||
'terminal.customTheme.color.black': 'Чёрный',
|
||||
@@ -652,6 +664,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToLeft': 'Закрыть вкладки слева',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
@@ -814,6 +827,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.backspaceBehavior': 'Клавиша Backspace',
|
||||
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
|
||||
'serial.encoding.pendingInput': 'Отправьте или очистите текущий ввод перед сменой кодировки.',
|
||||
'serial.field.byteOrientedBackspace': 'Побайтовый Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Отправляет по одному DEL на байт, чтобы устройство удалило весь многобайтовый символ (для устройств, удаляющих побайтово). Отключите для редакторов, удаляющих посимвольно (например, readline в Linux).',
|
||||
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
@@ -827,6 +843,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
|
||||
'serial.connectAndSave': 'Подключить и сохранить',
|
||||
'serial.edit.title': 'Настройки последовательного порта',
|
||||
'serial.field.username': 'Имя пользователя',
|
||||
'serial.field.password': 'Пароль',
|
||||
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',
|
||||
'serial.field.clearPassword': 'Очистить сохранённый пароль',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Требуется аутентификация',
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'Не удалось прочитать {count} сохранённых парольных фраз ключей; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'Не удалось прочитать {count} сохранённых паролей прокси; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
|
||||
'vault.hosts.allHosts': 'Все хосты',
|
||||
'vault.hosts.pinned': 'Закреплённые',
|
||||
@@ -154,6 +155,11 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Импортировать все сеансы с сохранением структуры папок.',
|
||||
'vault.import.securecrt.file': 'Один файл сеанса',
|
||||
'vault.import.securecrt.fileDesc': 'Импортировать один файл сеанса SecureCRT .ini.',
|
||||
'vault.import.finalshell.promptTitle': 'Импорт из FinalShell',
|
||||
'vault.import.finalshell.folder': 'Папка conn',
|
||||
'vault.import.finalshell.folderDesc': 'Импортировать все JSON-подключения с сохранением структуры папок.',
|
||||
'vault.import.finalshell.file': 'Один файл подключения',
|
||||
'vault.import.finalshell.fileDesc': 'Импортировать одно JSON-подключение FinalShell.',
|
||||
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
|
||||
'vault.import.toast.start': 'Импорт из {format}...',
|
||||
@@ -299,6 +305,8 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Остановить и удалить',
|
||||
'pf.form.autoStart': 'Автозапуск',
|
||||
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
|
||||
'pf.form.autoReconnect': 'Автопереподключение',
|
||||
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Новая папка',
|
||||
@@ -747,8 +755,13 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.os.title': 'Операционная система',
|
||||
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
|
||||
'hostDetails.os.title': 'Определение системы',
|
||||
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
|
||||
'hostDetails.os.auto': 'Автоматически',
|
||||
'hostDetails.os.unknown': 'Другая / неизвестно',
|
||||
'hostDetails.os.selection': 'Выбор системы',
|
||||
'hostDetails.os.current': 'Текущая система',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
|
||||
@@ -7,9 +7,11 @@ import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
@@ -83,6 +85,21 @@ test("localized settings include OSC desktop notification labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include bar cursor width labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.cursor.barWidth",
|
||||
"settings.terminal.cursor.barWidth.desc",
|
||||
];
|
||||
|
||||
for (const locale of [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
]) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing bar cursor width labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'vault.groups.deleteDialog.managedDesc',
|
||||
'vault.groups.deleteDialog.mixedDesc',
|
||||
'vault.groups.deleteDialog.managedWarning',
|
||||
'vault.groups.deleteDialog.managedFile',
|
||||
] as const;
|
||||
|
||||
test('managed group delete warning copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing managed group delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('managed group delete warning mentions data loss in every locale', () => {
|
||||
const MARKERS = ['备份', '備份', 'copia de seguridad', 'резервную копию', 'Back up'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const text = messages['vault.groups.deleteDialog.managedWarning'];
|
||||
assert.match(
|
||||
text ?? '',
|
||||
/SSH/,
|
||||
`${locale} warning should reference the SSH config file`,
|
||||
);
|
||||
assert.ok(
|
||||
MARKERS.some((marker) => text?.includes(marker)),
|
||||
`${locale} warning should tell the user to back up the file`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('linked file line interpolates the file path', () => {
|
||||
for (const messages of [en, ru, es, zhCN, zhTW]) {
|
||||
assert.match(
|
||||
messages['vault.groups.deleteDialog.managedFile'] ?? '',
|
||||
/\{file\}/,
|
||||
'managedFile copy should include the {file} placeholder',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -4,10 +4,12 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已压缩',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活动',
|
||||
'ai.chat.activity.plan': '计划',
|
||||
@@ -53,6 +55,15 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.headers': '自定义 HTTP 请求头',
|
||||
'ai.providers.headers.help': '为此服务添加可选请求头。内容会隐藏显示并在保存时加密。',
|
||||
'ai.providers.headers.name': '请求头名称',
|
||||
'ai.providers.headers.value': '请求头内容',
|
||||
'ai.providers.headers.add': '添加请求头',
|
||||
'ai.providers.headers.remove': '删除请求头',
|
||||
'ai.providers.headers.invalid': '请填写有效且不重复的请求头。Content-Length 和 Transfer-Encoding 由应用自动设置。',
|
||||
'ai.providers.headers.loadError': '无法解锁已保存的请求头。请取消编辑,解锁系统钥匙串后重试。',
|
||||
'ai.providers.headers.saveError': '无法安全保存。请确认系统钥匙串可用后重试。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
@@ -225,20 +236,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '默认',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大轮次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
|
||||
'ai.codebuddy.maxBudget': '预算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
|
||||
'ai.codebuddy.persistSession': '会话持久化',
|
||||
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '文件检查点',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
|
||||
@@ -273,6 +288,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安装后在它自己的配置里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.mimo.detecting': '检测中...',
|
||||
'ai.mimo.detected': '已检测到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路径:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。请安装或在下方指定可执行文件路径。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '检查',
|
||||
'ai.mimo.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
@@ -350,21 +377,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': '其他',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '配置片段',
|
||||
'ai.externalMcp.universalSetupPrompt': '通用安装提示词',
|
||||
'ai.externalMcp.addToCodex': '添加到 Codex',
|
||||
'ai.externalMcp.addToClaude': '添加到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '添加到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目和 Netcatty Skill。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目和 Netcatty Skill。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目和 Netcatty Skill。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
|
||||
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
|
||||
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
|
||||
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': '将片段合并到 Cursor MCP 配置中,并保留已有服务器。',
|
||||
'ai.externalMcp.other.description': '将安装提示词复制到其他终端 Agent,或把 MCP 片段合并到对应客户端配置。',
|
||||
'ai.externalMcp.status.unavailable': '不可用',
|
||||
'ai.externalMcp.status.disabled': '已关闭',
|
||||
'ai.externalMcp.status.running': '运行中',
|
||||
@@ -471,6 +501,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': '使用 “{id}”',
|
||||
'ai.chat.thinkingLevel': '思考强度',
|
||||
'ai.chat.thinkingOff': '关闭',
|
||||
'ai.chat.thinkingDefault': '跟随提供商默认',
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
@@ -497,6 +528,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
@@ -575,8 +613,16 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '改变位置',
|
||||
'terminal.layer.dockLeft': '左侧',
|
||||
'terminal.layer.dockRight': '右侧',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
|
||||
'terminal.layer.layoutSaveFailed': '布局保存失败',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'sort.ip': '按 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -61,6 +62,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'field.name': '名称',
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'placeholder.searchHosts': '搜索主机或标签...',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
@@ -118,6 +120,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.terminal.sidePanel.pane.system': '系统',
|
||||
'settings.terminal.sidePanel.pane.notes': '笔记',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': '本地 Shell 侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': '打开本地 Shell 时展开侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': '打开本地 Shell 后自动打开一个侧边栏。',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': '默认侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
|
||||
'settings.terminal.localShellSidePanel.pane.files': '文件',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
@@ -534,7 +542,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -673,6 +681,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.mixedDesc': '该分组包含从关联 SSH config 文件导入的主机。其托管子分组下的所有主机都将被删除,包括不受该文件管理的主机;其余主机将移动到根级别,除非您在下方选择同时删除。',
|
||||
'vault.groups.deleteDialog.managedWarning': '删除该分组会同时清空关联 SSH config 文件中的对应条目,从该文件导入的配置可能会丢失,其他使用该文件的应用也会受到影响。请先备份文件再继续。',
|
||||
'vault.groups.deleteDialog.sourcesChanged': '此次删除涉及的托管文件已发生变化。请检查文件并重新确认。',
|
||||
'vault.groups.deleteDialog.managedFile': '关联文件:{file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.deleteDialog.bulkTitle': '删除所选分组',
|
||||
'vault.groups.deleteDialog.bulkDesc': '这将永久删除所选分组,并把其中的主机移动到根级别。',
|
||||
@@ -731,6 +743,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 个已保存的密钥口令无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 个已保存的代理密码无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
@@ -781,6 +794,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': '一次导入全部会话,并保留目录结构。',
|
||||
'vault.import.securecrt.file': '单个会话文件',
|
||||
'vault.import.securecrt.fileDesc': '导入一个 SecureCRT .ini 会话。',
|
||||
'vault.import.finalshell.promptTitle': '从 FinalShell 导入',
|
||||
'vault.import.finalshell.folder': 'conn 文件夹',
|
||||
'vault.import.finalshell.folderDesc': '导入全部 JSON 连接,并保留目录结构。',
|
||||
'vault.import.finalshell.file': '单个连接文件',
|
||||
'vault.import.finalshell.fileDesc': '导入一个 FinalShell JSON 连接。',
|
||||
'vault.import.csv.tip': '批量导入:可使用 CSV 模板填写后导入。',
|
||||
'vault.import.csv.downloadTemplate': '下载 CSV 模板',
|
||||
'vault.import.toast.start': '正在从 {format} 导入...',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
'scripts.actions.runOnAllTabs': '在所有标签页运行',
|
||||
'scripts.actions.copyCommand': '复制命令',
|
||||
'scripts.actions.copyCommand.copied': '命令已复制',
|
||||
'scripts.actions.copyCommand.failed': '复制命令失败',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 个标签处于密码/敏感输入,已跳过',
|
||||
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
'terminal.pasteConfirm.title': '要粘贴多行文本吗?',
|
||||
'terminal.pasteConfirm.summary': '将向会话发送 {lines} 行({chars} 个字符)。',
|
||||
'terminal.pasteConfirm.preview': '预览(可编辑)',
|
||||
'terminal.pasteConfirm.send': '发送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行发送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
@@ -269,6 +278,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字体平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
|
||||
'settings.terminal.font.ligatures': '字体连字',
|
||||
'settings.terminal.font.ligatures.desc': '支持连字的字体把 != 这类字符显示成一个符号',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
@@ -276,6 +287,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '块',
|
||||
'settings.terminal.cursor.style.bar': '竖线',
|
||||
'settings.terminal.cursor.style.underline': '下划线',
|
||||
'settings.terminal.cursor.barWidth': '竖线光标粗细',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等应用使用竖线光标时的宽度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.cursor.highlightLine': '高亮当前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '为光标所在行加上淡色背景,方便定位当前输入位置',
|
||||
@@ -289,6 +302,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右键长按显示菜单',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右键设为粘贴或选词时,按住半秒打开菜单。不影响接管鼠标的终端应用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
|
||||
@@ -322,12 +337,21 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '粘贴多行前先询问',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'粘贴多行文本时先弹出确认对话框,显示行数和字符数、可编辑的预览,并提供发送 / 逐行发送 / 取消操作。可保护不支持括号粘贴的网络设备 CLI(Cisco IOS、华为 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '确认阈值(行数)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'粘贴内容达到该行数时才弹出确认对话框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
@@ -343,6 +367,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
|
||||
'settings.terminal.behavior.tabDoubleClick': '标签页双击行为',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': '选择双击顶部终端标签页时执行的操作。',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': '复制会话(新建连接)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': '复制标签页(复用连接)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': '无操作',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
|
||||
@@ -722,6 +751,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToLeft': '关闭左侧标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +922,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.backspaceBehavior': '回退键',
|
||||
'serial.field.backspaceBehaviorDesc': '如果网络设备不响应默认回退键,请改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '请先提交或清空当前输入,再切换编码。',
|
||||
'serial.field.byteOrientedBackspace': '按字节退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '发送与字符字节数相同的 DEL,使设备删除整个多字节字符(适用于按字节删除的设备)。若为逐字符删除的行编辑器(如 Linux readline),请关闭。',
|
||||
'serial.backspace.default': '默认(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字符编码',
|
||||
@@ -905,6 +938,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
'serial.field.username': '用户名',
|
||||
'serial.field.password': '密码',
|
||||
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',
|
||||
'serial.field.clearPassword': '清除已保存的密码',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
|
||||
@@ -133,8 +133,13 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '操作系统',
|
||||
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
|
||||
'hostDetails.os.title': '系统识别',
|
||||
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
|
||||
'hostDetails.os.auto': '自动识别',
|
||||
'hostDetails.os.unknown': '其他 / 未知',
|
||||
'hostDetails.os.selection': '识别方式',
|
||||
'hostDetails.os.current': '当前系统',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
@@ -498,6 +503,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
@@ -573,6 +579,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
@@ -639,6 +646,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗体)',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
@@ -974,6 +982,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
'pf.form.autoReconnect': '自动重连',
|
||||
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
|
||||
@@ -4,10 +4,12 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已壓縮',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活動',
|
||||
'ai.chat.activity.plan': '計畫',
|
||||
@@ -225,20 +227,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '環境變數',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '預設',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大輪次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
|
||||
'ai.codebuddy.maxBudget': '預算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
|
||||
'ai.codebuddy.persistSession': '工作階段持久化',
|
||||
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
|
||||
@@ -273,6 +279,18 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安裝後在它自己的設定裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.mimo.detecting': '偵測中...',
|
||||
'ai.mimo.detected': '已偵測到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路徑:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '檢查',
|
||||
'ai.mimo.resetPath': '重設',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
|
||||
@@ -349,21 +367,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': '其他',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '設定片段',
|
||||
'ai.externalMcp.universalSetupPrompt': '通用安裝提示詞',
|
||||
'ai.externalMcp.addToCodex': '新增到 Codex',
|
||||
'ai.externalMcp.addToClaude': '新增到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '新增到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
|
||||
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
|
||||
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
|
||||
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目和 Netcatty Skill。請重新啟動 Codex 或開啟新工作階段。',
|
||||
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目和 Netcatty Skill。請重新啟動 Claude Code 或開啟新工作階段。',
|
||||
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目和 Netcatty Skill。請重新啟動 Grok 或開啟新工作階段。',
|
||||
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
|
||||
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
|
||||
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
|
||||
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': '將片段合併到 Cursor MCP 設定中,並保留已有伺服器。',
|
||||
'ai.externalMcp.other.description': '將安裝提示詞複製到其他終端 Agent,或把 MCP 片段合併到對應用戶端設定。',
|
||||
'ai.externalMcp.status.unavailable': '無法使用',
|
||||
'ai.externalMcp.status.disabled': '已關閉',
|
||||
'ai.externalMcp.status.running': '執行中',
|
||||
@@ -470,6 +491,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.useCustomModel': '使用「{id}」',
|
||||
'ai.chat.thinkingLevel': '思考強度',
|
||||
'ai.chat.thinkingOff': '關閉',
|
||||
'ai.chat.thinkingDefault': '跟隨提供商預設',
|
||||
'ai.chat.pinModel': '釘選模型',
|
||||
'ai.chat.unpinModel': '取消釘選',
|
||||
'ai.chat.loadingModels': '正在載入模型...',
|
||||
@@ -496,6 +518,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuMentionNote': '提及筆記',
|
||||
'ai.chat.mentionNoteSearch': '搜尋筆記…',
|
||||
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
|
||||
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
|
||||
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
|
||||
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
|
||||
'ai.chat.untitledNote': '未命名筆記',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
@@ -574,8 +603,16 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '變更位置',
|
||||
'terminal.layer.dockLeft': '左側',
|
||||
'terminal.layer.dockRight': '右側',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
|
||||
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sort.newest': '從新到舊',
|
||||
'sort.oldest': '從舊到新',
|
||||
'sort.group': '依群組',
|
||||
'sort.ip': '依 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '型別',
|
||||
'auth.keyType': '型別 {type}',
|
||||
@@ -63,7 +64,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'field.selectHosts': '選擇主機',
|
||||
'placeholder.workspaceName': '工作區名稱',
|
||||
'placeholder.sessionName': '工作階段名稱',
|
||||
'placeholder.searchHosts': '搜尋主機...',
|
||||
'placeholder.searchHosts': '搜尋主機或標籤...',
|
||||
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
|
||||
'credentials.protectionUnavailable.title': '憑證保護不可用',
|
||||
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
|
||||
@@ -121,6 +122,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.terminal.sidePanel.pane.system': '系統',
|
||||
'settings.terminal.sidePanel.pane.notes': '筆記',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': '本機 Shell 側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': '開啟本機 Shell 時展開側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': '開啟本機 Shell 後自動開啟一個側邊欄。',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': '預設側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
|
||||
'settings.terminal.localShellSidePanel.pane.files': '檔案',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系統',
|
||||
@@ -537,7 +544,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -676,6 +683,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': '刪除群組',
|
||||
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
|
||||
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
|
||||
'vault.groups.deleteDialog.mixedDesc': '該群組包含從關聯 SSH config 檔案匯入的主機。其受管理子群組下的所有主機都將被刪除,包括不受該檔案管理的主機;其餘主機將移動到根層級,除非您在下方選擇同時刪除。',
|
||||
'vault.groups.deleteDialog.managedWarning': '刪除該群組會同時清空關聯 SSH config 檔案中的對應條目,從該檔案匯入的設定可能會遺失,其他使用該檔案的應用也會受到影響。請先備份檔案再繼續。',
|
||||
'vault.groups.deleteDialog.sourcesChanged': '此次刪除涉及的受管理檔案已發生變化。請檢查檔案並重新確認。',
|
||||
'vault.groups.deleteDialog.managedFile': '關聯檔案:{file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
|
||||
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
|
||||
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
|
||||
@@ -734,6 +745,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 個已儲存的金鑰密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 個已儲存的代理密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
|
||||
'vault.hosts.allHosts': '全部主機',
|
||||
'vault.hosts.pinned': '已置頂',
|
||||
@@ -784,6 +796,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': '一次匯入全部工作階段,並保留目錄結構。',
|
||||
'vault.import.securecrt.file': '單一工作階段檔案',
|
||||
'vault.import.securecrt.fileDesc': '匯入一個 SecureCRT .ini 工作階段。',
|
||||
'vault.import.finalshell.promptTitle': '從 FinalShell 匯入',
|
||||
'vault.import.finalshell.folder': 'conn 資料夾',
|
||||
'vault.import.finalshell.folderDesc': '匯入全部 JSON 連線,並保留目錄結構。',
|
||||
'vault.import.finalshell.file': '單一連線檔案',
|
||||
'vault.import.finalshell.fileDesc': '匯入一個 FinalShell JSON 連線。',
|
||||
'vault.import.csv.tip': '批次匯入:可使用 CSV 模板填寫後匯入。',
|
||||
'vault.import.csv.downloadTemplate': '下載 CSV 模板',
|
||||
'vault.import.toast.start': '正在從 {format} 匯入...',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有分頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有分頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有分頁執行',
|
||||
'scripts.actions.copyCommand': '複製指令',
|
||||
'scripts.actions.copyCommand.copied': '指令已複製',
|
||||
'scripts.actions.copyCommand.failed': '複製指令失敗',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
'terminal.pasteConfirm.title': '要貼上多行文字嗎?',
|
||||
'terminal.pasteConfirm.summary': '將向工作階段傳送 {lines} 行({chars} 個字元)。',
|
||||
'terminal.pasteConfirm.preview': '預覽(可編輯)',
|
||||
'terminal.pasteConfirm.send': '傳送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行傳送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
@@ -269,6 +278,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字型平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
|
||||
'settings.terminal.font.ligatures': '字型連字',
|
||||
'settings.terminal.font.ligatures.desc': '支援連字的字型把 != 這類字元顯示成一個符號',
|
||||
'settings.terminal.font.linePadding': '行間距',
|
||||
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '終端模擬型別',
|
||||
@@ -276,6 +287,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '塊',
|
||||
'settings.terminal.cursor.style.bar': '豎線',
|
||||
'settings.terminal.cursor.style.underline': '下劃線',
|
||||
'settings.terminal.cursor.barWidth': '豎線游標粗細',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等應用程式使用豎線游標時的寬度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '游標閃爍',
|
||||
'settings.terminal.cursor.highlightLine': '高亮目前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '為游標所在行加上淡色背景,方便定位目前輸入位置',
|
||||
@@ -289,6 +302,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右鍵行為',
|
||||
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右鍵長按顯示選單',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右鍵設為貼上或選詞時,按住半秒開啟選單。不影響接管滑鼠的終端應用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
|
||||
@@ -322,12 +337,21 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '貼上多行前先詢問',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'貼上多行文字時先彈出確認對話框,顯示行數和字元數、可編輯的預覽,並提供傳送 / 逐行傳送 / 取消操作。可保護不支援括號貼上的網路設備 CLI(Cisco IOS、華為 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '確認門檻(行數)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'貼上內容達到該行數時才彈出確認對話框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
@@ -343,6 +367,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.tabDoubleClick': '分頁雙擊行為',
|
||||
'settings.terminal.behavior.tabDoubleClick.desc': '選擇雙擊頂部終端分頁時要執行的動作。',
|
||||
'settings.terminal.behavior.tabDoubleClick.duplicate': '複製工作階段(新連線)',
|
||||
'settings.terminal.behavior.tabDoubleClick.copy': '複製分頁(重用連線)',
|
||||
'settings.terminal.behavior.tabDoubleClick.disabled': '無動作',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
|
||||
@@ -722,6 +751,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
'tabs.closeToLeft': '關閉左側分頁',
|
||||
'tabs.closeToRight': '關閉右側分頁',
|
||||
'tabs.closeAll': '關閉所有分頁',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +922,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
|
||||
'serial.field.backspaceBehavior': '退格鍵',
|
||||
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '請先提交或清空目前輸入,再切換編碼。',
|
||||
'serial.field.byteOrientedBackspace': '按位元組退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '傳送與字元位元組數相同的 DEL,使裝置刪除整個多位元組字元(適用於按位元組刪除的裝置)。若為逐字元刪除的行編輯器(如 Linux readline),請關閉。',
|
||||
'serial.backspace.default': '預設(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
@@ -905,6 +938,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '連線並儲存',
|
||||
'serial.edit.title': '序列埠設定',
|
||||
'serial.field.username': '使用者名稱',
|
||||
'serial.field.password': '密碼',
|
||||
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',
|
||||
'serial.field.clearPassword': '清除已儲存的密碼',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要驗證',
|
||||
|
||||
@@ -132,8 +132,13 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '作業系統',
|
||||
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
|
||||
'hostDetails.os.title': '系統識別',
|
||||
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
|
||||
'hostDetails.os.auto': '自動辨識',
|
||||
'hostDetails.os.unknown': '其他 / 未知',
|
||||
'hostDetails.os.selection': '辨識方式',
|
||||
'hostDetails.os.current': '目前系統',
|
||||
'hostDetails.os.freebsd': 'FreeBSD',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
@@ -498,6 +503,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
@@ -573,6 +579,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
@@ -639,6 +646,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '標示色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗體)',
|
||||
'terminal.customTheme.color.cursor': '游標',
|
||||
'terminal.customTheme.color.selection': '選區',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
@@ -974,6 +982,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '關閉並刪除',
|
||||
'pf.form.autoStart': '自動啟動',
|
||||
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
|
||||
'pf.form.autoReconnect': '自動重連',
|
||||
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本機',
|
||||
|
||||
@@ -78,3 +78,57 @@ test('prepared apply callback runs after the snapshot with sentinel protection',
|
||||
assert.equal(observedSentinel, true);
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
});
|
||||
|
||||
for (const localRestore of [false, true]) {
|
||||
for (const encryptionFails of [false, true]) {
|
||||
test(`protected header preparation (${localRestore ? 'restore' : 'sync'}, ${encryptionFails ? 'failure' : 'success'})`, async () => {
|
||||
const { prepareSyncPayloadApply, prepareLocalVaultPayloadApply } = await import('./syncPayload.ts');
|
||||
const { STORAGE_KEY_THEME, STORAGE_KEY_AI_PROVIDERS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
|
||||
const calls: string[] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => {
|
||||
calls.push('encrypt');
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
if (encryptionFails) throw new Error('keychain locked');
|
||||
return sealed;
|
||||
} },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
localStorageValues.set(STORAGE_KEY_THEME, 'light');
|
||||
const payload: SyncPayload = { ...emptyPayload(), settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
const importers = { importVaultData: () => {
|
||||
calls.push('import');
|
||||
assert.notEqual(readInterruptedVaultApply(), null);
|
||||
} };
|
||||
const applying = applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => { calls.push('snapshot'); return emptyPayload(); },
|
||||
prepareApply: () => localRestore
|
||||
? prepareLocalVaultPayloadApply(payload, importers, { prepareConvergentRestore: async () => {
|
||||
calls.push('prepareReplica');
|
||||
return async () => { calls.push('commitReplica'); };
|
||||
} })
|
||||
: prepareSyncPayloadApply(payload, importers),
|
||||
translateProtectiveBackupFailure: (message) => message,
|
||||
});
|
||||
if (encryptionFails) {
|
||||
await assert.rejects(applying);
|
||||
assert.deepEqual(calls, ['encrypt']);
|
||||
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'light');
|
||||
assert.equal(localStorageValues.has(STORAGE_KEY_AI_PROVIDERS), false);
|
||||
} else {
|
||||
await applying;
|
||||
assert.deepEqual(calls, localRestore
|
||||
? ['encrypt', 'prepareReplica', 'snapshot', 'import', 'commitReplica']
|
||||
: ['encrypt', 'snapshot', 'import']);
|
||||
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'dark');
|
||||
assert.equal(JSON.parse(localStorageValues.get(STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders['X-Tenant'], sealed);
|
||||
}
|
||||
assert.equal(readInterruptedVaultApply(), null);
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('compose history is isolated per terminal and removed when its session closes', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'echo a\necho again');
|
||||
recordComposeBarHistory('b', 'echo b');
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['echo a\necho again']);
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
|
||||
pruneComposeBarHistory(['b']);
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('late send completion cannot restore a closed session or overwrite another send', async () => {
|
||||
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
|
||||
pruneComposeBarHistory([]);
|
||||
const first = createComposeBarHistoryRecorder('a');
|
||||
const second = createComposeBarHistoryRecorder('a');
|
||||
second('second');
|
||||
first('first');
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['first', 'second']);
|
||||
const late = createComposeBarHistoryRecorder('a');
|
||||
pruneComposeBarHistory([]);
|
||||
late('late');
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
});
|
||||
|
||||
|
||||
test('failed submissions unblock later successes in submission order', async () => {
|
||||
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
|
||||
pruneComposeBarHistory([]);
|
||||
const failed = createComposeBarHistoryRecorder('a');
|
||||
const accepted = createComposeBarHistoryRecorder('a');
|
||||
accepted('accepted');
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
failed();
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
|
||||
// Session-only memory: survives closing the bar and moving a terminal into a
|
||||
// workspace, but is discarded when that terminal session is removed.
|
||||
type PendingSend = { settled: boolean; command?: string };
|
||||
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
|
||||
|
||||
export function getComposeBarHistory(sessionId: string): readonly string[] {
|
||||
return histories.get(sessionId)?.entries ?? [];
|
||||
}
|
||||
|
||||
export function createComposeBarHistoryRecorder(sessionId: string): (command?: string) => void {
|
||||
let history = histories.get(sessionId);
|
||||
if (!history) {
|
||||
history = { entries: [], pending: [] };
|
||||
histories.set(sessionId, history);
|
||||
}
|
||||
const target = history;
|
||||
const submission: PendingSend = { settled: false };
|
||||
target.pending.push(submission);
|
||||
// Capture the session's object before an async send. A late completion after
|
||||
// session removal cannot recreate that session's history in the map.
|
||||
return (command) => {
|
||||
if (submission.settled) return;
|
||||
submission.settled = true;
|
||||
submission.command = command;
|
||||
// Settle failures too, so they cannot block later successful submissions.
|
||||
while (target.pending[0]?.settled) {
|
||||
const next = target.pending.shift()!;
|
||||
if (next.command !== undefined) {
|
||||
target.entries = appendComposeBarHistory(target.entries, next.command);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function recordComposeBarHistory(sessionId: string, command: string): void {
|
||||
createComposeBarHistoryRecorder(sessionId)(command);
|
||||
}
|
||||
|
||||
export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
|
||||
const active = new Set(sessionIds);
|
||||
for (const id of histories.keys()) {
|
||||
if (!active.has(id)) histories.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { findEditorSftpOwnerTabId, registerEditorSftpOwnerResolver } from "./editorSftpOwnerRegistry";
|
||||
|
||||
test("resolves the owning tab for a registered SFTP connection id", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["left-abc", "right-def"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("left-abc"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("right-def"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("left-unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the stable pane tab id when the connection id is stale", () => {
|
||||
// Browse reconnects regenerate connection ids; an unsaved editor may still
|
||||
// reference the pre-reconnect id, so owner resolution must also match on the
|
||||
// stable pane tab id.
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn_new"],
|
||||
paneTabIds: ["pane_1"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("unregistering a resolver stops it from resolving owners", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-x"],
|
||||
ownerTabId: "tab-x",
|
||||
}));
|
||||
unregister();
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-x"), null);
|
||||
});
|
||||
|
||||
test("resolvers without an owner tab id never match", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-y"],
|
||||
ownerTabId: null,
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-y"), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Maps live SFTP connection ids to the top-level tab that owns the panel
|
||||
* hosting them (a terminal/workspace tab for SftpSidePanel instances).
|
||||
*
|
||||
* Editor tabs record the SFTP `connection.id` as their `sessionId` — not the
|
||||
* owning terminal session id — so batch-close logic that wants to keep an
|
||||
* editor's owning tab open after a cancelled dirty-save prompt must resolve
|
||||
* the owner through this registry instead of comparing `editorTab.sessionId`
|
||||
* against terminal session ids.
|
||||
*
|
||||
* Each mounted SftpSidePanel registers a resolver that reports the connection
|
||||
* ids it currently owns plus its host tab id; the top-level SftpView either
|
||||
* registers nothing (its owner is not a closable terminal tab) or registers
|
||||
* with a non-terminal owner id that simply never matches.
|
||||
*/
|
||||
export interface EditorSftpOwnerSnapshot {
|
||||
connectionIds: readonly string[];
|
||||
/**
|
||||
* Stable SFTP pane tab ids (pane.id) hosted by this panel. Unlike connection
|
||||
* ids these survive browse reconnects that regenerate connection ids, so
|
||||
* owner resolution can fall back to them when an editor still references a
|
||||
* pre-reconnect connection id.
|
||||
*/
|
||||
paneTabIds?: readonly string[];
|
||||
ownerTabId: string | null;
|
||||
}
|
||||
|
||||
type EditorSftpOwnerResolver = () => EditorSftpOwnerSnapshot;
|
||||
|
||||
const resolvers = new Set<EditorSftpOwnerResolver>();
|
||||
|
||||
export const registerEditorSftpOwnerResolver = (resolver: EditorSftpOwnerResolver): (() => void) => {
|
||||
resolvers.add(resolver);
|
||||
return () => {
|
||||
resolvers.delete(resolver);
|
||||
};
|
||||
};
|
||||
|
||||
export const findEditorSftpOwnerTabId = (
|
||||
connectionId: string | undefined,
|
||||
paneTabId?: string,
|
||||
): string | null => {
|
||||
if (!connectionId && !paneTabId) return null;
|
||||
for (const resolve of resolvers) {
|
||||
const snapshot = resolve();
|
||||
if (!snapshot.ownerTabId) continue;
|
||||
if (connectionId && snapshot.connectionIds.includes(connectionId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
if (paneTabId && snapshot.paneTabIds?.includes(paneTabId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getPendingMultilinePasteConfirm,
|
||||
requestMultilinePasteConfirm,
|
||||
respondMultilinePasteConfirm,
|
||||
subscribeMultilinePasteConfirm,
|
||||
} from "./multilinePasteConfirmStore";
|
||||
|
||||
test("request stores the pending paste and respond resolves it", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.lineCount, 2);
|
||||
|
||||
respondMultilinePasteConfirm("line-by-line", "line1\nline2\nline3");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "line-by-line", text: "line1\nline2\nline3" });
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
});
|
||||
|
||||
test("respond without text falls back to the original request text", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
respondMultilinePasteConfirm("send");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "send", text: "line1\nline2" });
|
||||
});
|
||||
|
||||
test("a newer paste request supersedes the pending one as cancelled", async () => {
|
||||
const first = requestMultilinePasteConfirm({ text: "a\nb", lineCount: 2, charCount: 3 });
|
||||
const second = requestMultilinePasteConfirm({ text: "c\nd", lineCount: 2, charCount: 3 });
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.text, "c\nd");
|
||||
|
||||
respondMultilinePasteConfirm("send", "c\nd");
|
||||
assert.deepEqual(await first, { action: "cancel", text: "a\nb" });
|
||||
assert.deepEqual(await second, { action: "send", text: "c\nd" });
|
||||
});
|
||||
|
||||
test("respond without a pending request is a no-op and listeners are notified", async () => {
|
||||
respondMultilinePasteConfirm("send");
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
|
||||
const events: Array<string | null> = [];
|
||||
const unsubscribe = subscribeMultilinePasteConfirm(() => {
|
||||
events.push(getPendingMultilinePasteConfirm()?.id ?? "cleared");
|
||||
});
|
||||
const pending = requestMultilinePasteConfirm({ text: "x", lineCount: 1, charCount: 1 });
|
||||
const pendingId = getPendingMultilinePasteConfirm()?.id ?? null;
|
||||
respondMultilinePasteConfirm("cancel", "x");
|
||||
await pending;
|
||||
unsubscribe();
|
||||
|
||||
assert.ok(pendingId);
|
||||
assert.deepEqual(events, [pendingId, "cleared"]);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* App-level store for the "paste multiple lines" confirmation dialog (#3398).
|
||||
*
|
||||
* Terminal paste handlers call `requestMultilinePasteConfirm` from anywhere in
|
||||
* the renderer and await the user's decision; `MultilinePasteConfirmHost`
|
||||
* (mounted once in App) renders the dialog and resolves the pending request.
|
||||
*/
|
||||
|
||||
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
|
||||
|
||||
export interface MultilinePasteConfirmRequest {
|
||||
id: string;
|
||||
/** Clipboard text the dialog previews (editable by the user). */
|
||||
text: string;
|
||||
lineCount: number;
|
||||
charCount: number;
|
||||
/** Restore the originating terminal's focus after the modal releases it. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface MultilinePasteConfirmResponse {
|
||||
action: MultilinePasteConfirmAction;
|
||||
/** Text to send: the preview content, possibly edited by the user. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
request: MultilinePasteConfirmRequest;
|
||||
resolve: (response: MultilinePasteConfirmResponse) => void;
|
||||
}
|
||||
|
||||
let pending: PendingRequest | null = null;
|
||||
let nextRequestId = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export function requestMultilinePasteConfirm(
|
||||
request: Omit<MultilinePasteConfirmRequest, "id">,
|
||||
): Promise<MultilinePasteConfirmResponse> {
|
||||
// Only one confirmation dialog can be pending; a newer paste supersedes the
|
||||
// older one (the stale promise resolves as "cancel" so its paste is dropped).
|
||||
if (pending) {
|
||||
const stale = pending;
|
||||
pending = null;
|
||||
stale.resolve({ action: "cancel", text: stale.request.text });
|
||||
}
|
||||
nextRequestId += 1;
|
||||
const requestWithId: MultilinePasteConfirmRequest = {
|
||||
...request,
|
||||
id: `multiline-paste-${nextRequestId}`,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
pending = { request: requestWithId, resolve };
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function respondMultilinePasteConfirm(
|
||||
action: MultilinePasteConfirmAction,
|
||||
text?: string,
|
||||
): void {
|
||||
const current = pending;
|
||||
if (!current) return;
|
||||
pending = null;
|
||||
current.resolve({ action, text: text ?? current.request.text });
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Snapshot for `useSyncExternalStore`; stable reference while pending. */
|
||||
export const getPendingMultilinePasteConfirm =
|
||||
(): MultilinePasteConfirmRequest | null => pending?.request ?? null;
|
||||
|
||||
export function subscribeMultilinePasteConfirm(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -101,6 +101,28 @@ test("workspace host factory creates a complete serial session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("serial session factories preserve byteOrientedBackspace", () => {
|
||||
const quickSession = createSerialTerminalSession("session-q", {
|
||||
path: "COM4",
|
||||
baudRate: 9600,
|
||||
byteOrientedBackspace: false,
|
||||
});
|
||||
const savedHostSession = createHostTerminalSession("session-h", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
port: 115200,
|
||||
username: "",
|
||||
serialConfig: {
|
||||
path: "COM3",
|
||||
baudRate: 115200,
|
||||
byteOrientedBackspace: false,
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(quickSession.serialConfig?.byteOrientedBackspace, false);
|
||||
assert.equal(savedHostSession.serialConfig?.byteOrientedBackspace, false);
|
||||
});
|
||||
|
||||
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
|
||||
@@ -36,6 +36,8 @@ export type SettingsChromeSnapshot = {
|
||||
restoreTerminalCwd: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen: boolean;
|
||||
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,6 +70,8 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
restoreTerminalCwd: true,
|
||||
terminalSidePanelAutoOpen: false,
|
||||
terminalSidePanelAutoOpenTab: 'ai',
|
||||
localShellSidePanelAutoOpen: false,
|
||||
localShellSidePanelAutoOpenTab: 'scripts',
|
||||
} satisfies SettingsChromeSnapshot);
|
||||
|
||||
export function settingsChromeSnapshotsEqual(
|
||||
@@ -92,7 +96,9 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.showTabNumberBadges === b.showTabNumberBadges
|
||||
&& a.restoreTerminalCwd === b.restoreTerminalCwd
|
||||
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
|
||||
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
|
||||
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab
|
||||
&& a.localShellSidePanelAutoOpen === b.localShellSidePanelAutoOpen
|
||||
&& a.localShellSidePanelAutoOpenTab === b.localShellSidePanelAutoOpenTab;
|
||||
}
|
||||
|
||||
class SettingsChromeStore {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync.ts';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync.ts';
|
||||
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen.ts';
|
||||
import {
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN as enabledKey,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB as tabKey,
|
||||
} from '../../infrastructure/config/storageKeys.ts';
|
||||
|
||||
for (const pathway of ['ipc', 'storage'] as const) {
|
||||
test(`${pathway} receives local shell settings, rejects invalid tabs and cleans up`, async (t) => {
|
||||
let receive: ((payload: { key: string; value: unknown }) => void) | undefined;
|
||||
let unsubscribed = false;
|
||||
const fakeWindow = Object.assign(new EventTarget(), {
|
||||
netcatty: {
|
||||
onSettingsChanged(callback: typeof receive) {
|
||||
receive = callback;
|
||||
return () => { unsubscribed = true; };
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const [key, value] of Object.entries({ window: fakeWindow, IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, key, previous);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
});
|
||||
}
|
||||
let current = { enabled: false, tab: 'scripts' };
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Harness() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [tab, setTab] = useState<TerminalSidePanelAutoOpenTab>('scripts');
|
||||
current = { enabled, tab };
|
||||
const params = {
|
||||
localShellSidePanelAutoOpen: enabled,
|
||||
localShellSidePanelAutoOpenTab: tab,
|
||||
setLocalShellSidePanelAutoOpenState: setEnabled,
|
||||
setLocalShellSidePanelAutoOpenTabState: setTab,
|
||||
};
|
||||
// Only local-shell events are dispatched; unrelated settings are not used.
|
||||
useSettingsIpcSync({ ...params, enabled: pathway === 'ipc' } as unknown as Parameters<typeof useSettingsIpcSync>[0]);
|
||||
useSettingsStorageSync({ ...params, enabled: pathway === 'storage' } as Parameters<typeof useSettingsStorageSync>[0]);
|
||||
return null;
|
||||
}
|
||||
async function send(key: string, value: unknown) {
|
||||
await act(async () => {
|
||||
if (pathway === 'ipc') receive!({ key, value });
|
||||
else fakeWindow.dispatchEvent(Object.assign(new Event('storage'), { key, newValue: value === null ? null : String(value) }));
|
||||
});
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
await send(enabledKey, true);
|
||||
await send(tabKey, 'notes');
|
||||
assert.deepEqual(current, { enabled: true, tab: 'notes' });
|
||||
await send(tabKey, 'invalid');
|
||||
await send(tabKey, null);
|
||||
await send(enabledKey, null);
|
||||
assert.deepEqual(current, { enabled: true, tab: 'notes' });
|
||||
if (pathway === 'ipc') {
|
||||
await send(enabledKey, 'false');
|
||||
assert.equal(current.enabled, true);
|
||||
}
|
||||
await send(enabledKey, false);
|
||||
await send(tabKey, 'history');
|
||||
assert.deepEqual(current, { enabled: false, tab: 'history' });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
renderer = undefined;
|
||||
if (pathway === 'ipc') assert.equal(unsubscribed, true);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
@@ -103,6 +105,8 @@ interface UseSettingsIpcSyncParams {
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -149,6 +153,8 @@ export function useSettingsIpcSync({
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
@@ -309,6 +315,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
|
||||
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
|
||||
setLocalShellSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
|
||||
setLocalShellSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -363,6 +375,8 @@ export function useSettingsIpcSync({
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
@@ -94,6 +96,8 @@ interface UseSettingsStorageSyncParams {
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen: boolean;
|
||||
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
showTabNumberBadges: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
@@ -143,6 +147,8 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -176,7 +182,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -185,7 +191,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -200,7 +206,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -210,7 +216,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -454,6 +460,17 @@ export function useSettingsStorageSync({
|
||||
setTerminalSidePanelAutoOpenTabState(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.localShellSidePanelAutoOpen) {
|
||||
setLocalShellSidePanelAutoOpenState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
|
||||
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.localShellSidePanelAutoOpenTab) {
|
||||
setLocalShellSidePanelAutoOpenTabState(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
@@ -574,6 +591,8 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
|
||||
@@ -41,8 +41,12 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
for (const ownerChange of retainedStatus === "interrupted" || retainedStatus === undefined ? ["same", "active", "completed"] : ["same"]) {
|
||||
for (const completedBeforeReplacement of ownerChange === "active" ? [false, true] : [false]) {
|
||||
for (const flushBeforeReply of ownerChange === "same" || retainedStatus === undefined ? [false] : [false, true]) {
|
||||
for (const replyRejects of ownerChange !== "same" && !completedBeforeReplacement && !flushBeforeReply ? [false, true] : [false]) {
|
||||
const batchExistingIdentity = retainedStatus !== undefined;
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}, owner=${ownerChange}, flush=${flushBeforeReply}, priorComplete=${completedBeforeReplacement}, reject=${replyRejects}`, async (t) => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
@@ -75,14 +79,19 @@ test(`superseded folder child settles when its completion was compacted into the
|
||||
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
|
||||
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
|
||||
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
directoryEntryIndex: 0,
|
||||
directoryEntryIdentity: newerPause ? createDirectoryEntryIdentity({
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin", size: 10, lastModified: 1,
|
||||
}) : "a".repeat(64),
|
||||
};
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
// Exercise the real large-history batching branch with one relevant retained row.
|
||||
const childBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => id === persisted.id,
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
hasTask: (id) => id === persisted.id || id.startsWith("flush-peer-"),
|
||||
// Peer rows only drive the real 512-entry flush; keep the relevant store
|
||||
// fixture small so unrelated history maintenance cannot mask ownership.
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates.filter((task) => !task.id.startsWith("flush-peer-"))),
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
@@ -106,20 +115,50 @@ test(`superseded folder child settles when its completion was compacted into the
|
||||
directoryEntryIndex: options.directoryEntryIndex,
|
||||
directoryEntryIdentity: options.directoryEntryIdentity,
|
||||
});
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
if (ownerChange !== "same") {
|
||||
const current = sftpTransferCenterStore.getTask(childId)!;
|
||||
if (completedBeforeReplacement) {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
}
|
||||
sftpTransferCenterStore.upsertTasks([{
|
||||
...current, status: "transferring", directoryEntryIdentity: "e".repeat(64), lifecycleEpoch: 1,
|
||||
}]);
|
||||
}
|
||||
if (ownerChange !== "active") {
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: ownerChange === "same" ? 0 : 1,
|
||||
});
|
||||
}
|
||||
if (flushBeforeReply) {
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
childBatcher.push({ ...persisted, id: `flush-peer-${index}`, status: "transferring" });
|
||||
}
|
||||
if (ownerChange === "active") {
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64),
|
||||
"automatic flush before the displaced reply must preserve the new owner");
|
||||
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined,
|
||||
"automatic flush must not recreate the compacted new owner");
|
||||
}
|
||||
if (replyRejects) throw new Error("old invocation transport failed");
|
||||
return completedBeforeReplacement ? {} : { superseded: true };
|
||||
},
|
||||
});
|
||||
running = resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
|
||||
onChildUpdate: (child) => batchExistingIdentity
|
||||
? childBatcher.push(child)
|
||||
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
|
||||
onChildSuperseded: (id) => childBatcher.discard(id),
|
||||
flushChildUpdates: () => childBatcher.flush(),
|
||||
onChildUpdate: (child, observation) => {
|
||||
if (batchExistingIdentity) {
|
||||
childBatcher.push(child, observation);
|
||||
return true;
|
||||
}
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]);
|
||||
},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
@@ -133,6 +172,15 @@ test(`superseded folder child settles when its completion was compacted into the
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.ok(childId);
|
||||
if (ownerChange !== "same") {
|
||||
childBatcher.flush();
|
||||
assert.equal((result as { success: boolean }).success, false, "the displaced walk must not report success");
|
||||
if (ownerChange === "active") {
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "transferring");
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64));
|
||||
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "old waiter must not recreate a failed row");
|
||||
return;
|
||||
}
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
|
||||
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
@@ -140,6 +188,10 @@ test(`superseded folder child settles when its completion was compacted into the
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
|
||||
assert.equal(
|
||||
@@ -696,6 +748,67 @@ test("single-file restart resume continues from checkpoint without page callback
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less SCP download resume keeps saved progress instead of restarting", async (t) => {
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
const originalGet = netcattyBridge.get;
|
||||
let startOptions: Record<string, unknown> | undefined;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp",
|
||||
closeSftp: async () => {},
|
||||
// Stat-less SCP host: size cannot be measured, so the bridge reports the
|
||||
// placeholder 0 with sizeKnown false.
|
||||
statSftp: async () => ({ type: "file", size: 0, lastModified: 5, sizeKnown: false }),
|
||||
startStreamTransfer: async (options: Record<string, unknown>) => {
|
||||
startOptions = options;
|
||||
return { transferId: "scp-download-resume" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await resumeTransferWithDedicatedSession({
|
||||
id: "scp-download-resume",
|
||||
fileName: "file.bin",
|
||||
sourcePath: "/remote/file.bin",
|
||||
targetPath: "/local/file.bin",
|
||||
sourceConnectionId: "old-sftp",
|
||||
sourceHostId: "h1",
|
||||
sourceHostLabel: "box",
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "interrupted",
|
||||
totalBytes: 100,
|
||||
transferredBytes: 20,
|
||||
checkpointBytes: 20,
|
||||
sourceLastModified: 5,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
reconnectRequired: true,
|
||||
}, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(result.success, true, result.error);
|
||||
// Unknown size must not misread the saved checkpoint as a shrunk source;
|
||||
// the download resumes from byte 20 instead of silently restarting.
|
||||
assert.equal(startOptions?.checkpointBytes, 20);
|
||||
assert.equal(startOptions?.totalBytes, 100);
|
||||
} finally {
|
||||
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("folder restart resume reports file-count progress without child page callbacks", async (t) => {
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
@@ -2131,3 +2244,202 @@ for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const completeDuringSetup of [false, true]) {
|
||||
test(`large-history replace recovery retransfers retained completed children into its empty stage: late=${completeDuringSetup}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
const originalGet = netcattyBridge.get;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const { createRequire } = await import("node:module");
|
||||
const tempDirBridge = createRequire(import.meta.url)("../../../electron/bridges/tempDirBridge.cjs");
|
||||
const root = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("folder-rebuild-test")}-`);
|
||||
const finalPath = path.join(root, "final");
|
||||
const stage = `${finalPath}.netcatty-retained-replace.part`;
|
||||
await fs.promises.mkdir(stage);
|
||||
await fs.promises.writeFile(path.join(stage, "b.txt"), "old contents");
|
||||
const parent: TransferTask = {
|
||||
id: "retained-replace", fileName: "folder", sourcePath: "/remote/folder", targetPath: finalPath,
|
||||
stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local",
|
||||
sourceHostId: "h1", direction: "download", status: "transferring", totalBytes: 2,
|
||||
transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
const retained: TransferTask = {
|
||||
...parent, id: "retained-replace-b", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, fileName: "b.txt",
|
||||
status: completeDuringSetup ? "interrupted" : "completed", totalBytes: 10, transferredBytes: 10, checkpointBytes: 10,
|
||||
sourceLastModified: 2, directoryEntryIndex: 1,
|
||||
directoryEntryIdentity: createDirectoryEntryIdentity({
|
||||
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, size: 10, lastModified: 2,
|
||||
}),
|
||||
};
|
||||
store.upsertTasks([parent, retained]);
|
||||
assert.ok(store.getTask(retained.id), "missing earlier manifest index retains the completed row");
|
||||
const batcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => !!store.getTask(id),
|
||||
upsertTasks: (updates) => store.upsertTasks(updates),
|
||||
});
|
||||
let releaseFirst!: () => void;
|
||||
const secondDispatched = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
netcattyBridge.get = originalGet;
|
||||
store.patchTask(parent.id, { status: "completed" });
|
||||
store.dismiss(parent.id);
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
|
||||
listSftp: async (_id: string, remotePath: string) => {
|
||||
if (completeDuringSetup) store.patchTask(retained.id, { status: "completed", ownerId: "dedicated-resume" });
|
||||
return remotePath === parent.sourcePath
|
||||
? ["a.txt", "b.txt"].map((name) => ({ name, type: "file", size: 10, lastModified: 2 })) : [];
|
||||
},
|
||||
mkdirLocal: async (target: string) => { await fs.promises.mkdir(target, { recursive: true }); },
|
||||
statLocal: async (target: string) => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(target);
|
||||
return { type: stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
statSftp: async (_id: string, source: string) => {
|
||||
if (source.endsWith("a.txt")) await secondDispatched;
|
||||
return { type: "file", size: 10, lastModified: 2 };
|
||||
},
|
||||
deleteLocalFile: async (target: string) => { await fs.promises.rm(target, { recursive: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { await fs.promises.rename(source, target); },
|
||||
startStreamTransfer: async (options: { targetPath: string; checkpointBytes: number }) => {
|
||||
assert.equal(options.checkpointBytes, 0, "an empty replacement stage cannot reuse byte checkpoints");
|
||||
await fs.promises.writeFile(options.targetPath, `new ${path.basename(options.targetPath)}`);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const result = await resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: [retained],
|
||||
flushChildUpdates: () => batcher.flush(),
|
||||
onChildUpdate: (task, observation) => {
|
||||
batcher.push(task, observation);
|
||||
if (task.id === retained.id && task.status === "completed") releaseFirst();
|
||||
return true;
|
||||
},
|
||||
onDirectoryCheckpointUpdate: (value) => store.patchTask(parent.id, { directoryResumeCheckpoint: value }),
|
||||
});
|
||||
batcher.flush();
|
||||
assert.equal(result.success, true, result.error);
|
||||
assert.deepEqual((await fs.promises.readdir(finalPath)).sort(), ["a.txt", "b.txt"],
|
||||
"published replacement must include the previously completed file");
|
||||
assert.equal(await fs.promises.readFile(path.join(finalPath, "b.txt"), "utf8"), "new b.txt");
|
||||
});
|
||||
}
|
||||
|
||||
for (const boundary of ["before-admission", "source-missing", "source-modified", "after-completion", "after-commit"] as const) {
|
||||
test(`dedicated recovery preserves replacement ownership ${boundary}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
const originalGet = netcattyBridge.get;
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
|
||||
getItem: () => "2", setItem: () => {}, removeItem: () => {},
|
||||
} });
|
||||
const parent: TransferTask = {
|
||||
id: `ownership-${boundary}`, fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder",
|
||||
sourceConnectionId: "local", targetConnectionId: "old", targetHostId: "h1", direction: "upload",
|
||||
status: "transferring", totalBytes: 2, transferredBytes: 0, speed: 0, startTime: 1,
|
||||
isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
const child: TransferTask = {
|
||||
...parent, id: `${parent.id}-a`, parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a", targetPath: "/remote/folder/a", fileName: "a", totalBytes: 1,
|
||||
status: "interrupted", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64), sourceLastModified: 1,
|
||||
};
|
||||
store.upsertTasks([parent, boundary === "before-admission"
|
||||
? { ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }
|
||||
: child]);
|
||||
const batch = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096, hasTask: (id) => id === child.id || id.startsWith("peer-"),
|
||||
upsertTasks: (rows) => store.upsertTasks(rows.filter((row) => !row.id.startsWith("peer-"))),
|
||||
});
|
||||
if (boundary === "before-admission") {
|
||||
for (let n = 0; n < 511; n++) batch.push({ ...child, id: `peer-${n}` });
|
||||
}
|
||||
let releaseB!: () => void;
|
||||
const bGate = new Promise<void>((resolve) => { releaseB = resolve; });
|
||||
let timedOut = false;
|
||||
const watchdog = setTimeout(() => { timedOut = true; releaseB(); }, 1000);
|
||||
t.after(() => {
|
||||
clearTimeout(watchdog);
|
||||
releaseB();
|
||||
batch.flush();
|
||||
store.patchTask(parent.id, { status: "completed" });
|
||||
store.dismiss(parent.id);
|
||||
netcattyBridge.get = originalGet;
|
||||
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
let starts = 0;
|
||||
let displaced = 0;
|
||||
let replaced = false;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated", closeSftp: async () => {
|
||||
if (boundary === "after-commit") {
|
||||
assert.equal(store.getTask(child.id), undefined, "completion must be committed before async cleanup");
|
||||
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
}
|
||||
},
|
||||
listLocalTree: async () => (boundary === "after-completion" ? ["a", "b"] : ["a"]).map((name) => ({
|
||||
localPath: `/local/folder/${name}`, relativePath: name, type: "file", size: 1, lastModified: 1,
|
||||
})),
|
||||
mkdirSftp: async () => {}, statLocal: async () => {
|
||||
if (boundary === "source-missing" || boundary === "source-modified") {
|
||||
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
if (boundary === "source-missing") return null;
|
||||
return { type: "file", size: 1, lastModified: 2 };
|
||||
}
|
||||
return { type: "file", size: 1, lastModified: 1 };
|
||||
},
|
||||
startStreamTransfer: async (options: { sourcePath: string }) => {
|
||||
starts++;
|
||||
if (options.sourcePath.endsWith("/b")) await bGate;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const result = await resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: [child],
|
||||
onChildSuperseded: (id) => { displaced++; batch.discard(id); },
|
||||
flushChildUpdates: () => batch.flush(),
|
||||
onChildUpdate: (task, observation) => {
|
||||
batch.push(task, observation);
|
||||
if (boundary === "after-completion" && task.id === child.id && task.status === "completed" && !replaced) {
|
||||
replaced = true;
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
} finally { releaseB(); }
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
batch.flush();
|
||||
assert.equal(timedOut, false);
|
||||
assert.equal(result.success, boundary === "after-commit", "only a committed success may precede a legitimate replacement");
|
||||
assert.equal(store.getTask(child.id)?.directoryEntryIdentity, "b".repeat(64));
|
||||
assert.equal(store.getTask(child.id)?.status, "transferring");
|
||||
if (boundary !== "after-completion" && boundary !== "after-commit") assert.equal(starts, 0, "source validation and a full batch must not bypass ownership");
|
||||
else assert.equal(displaced, boundary === "after-commit" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { runTransferAndWaitForOwner, TransferOwnerChangedError, type TransferOwnerObservation } from "./waitForTransferOwner";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
|
||||
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
@@ -62,7 +62,10 @@ export type DedicatedResumeResult = {
|
||||
|
||||
export type DedicatedResumeOptions = {
|
||||
children?: readonly TransferTask[];
|
||||
onChildUpdate?: (child: TransferTask) => void;
|
||||
/** Return true only when the consumer takes responsibility for disposing the observation. */
|
||||
onChildUpdate?: (child: TransferTask, observation?: TransferOwnerObservation) => unknown;
|
||||
onChildSuperseded?: (taskId: string) => void;
|
||||
flushChildUpdates?: () => void;
|
||||
onDirectoryCheckpointUpdate?: (checkpoint: TransferTask["directoryResumeCheckpoint"]) => void;
|
||||
shouldAbort?: () => boolean;
|
||||
};
|
||||
@@ -671,6 +674,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const validationError = validateTransferResumeSource(task, {
|
||||
size: sourceStat.size,
|
||||
lastModified: sourceStat.lastModified,
|
||||
sizeKnown: sourceStat.sizeKnown,
|
||||
}, { allowSourceGrowth });
|
||||
const classified = classifyResumeSourceValidationError(validationError);
|
||||
if (classified.kind === "modified") {
|
||||
@@ -1090,6 +1094,12 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
// the source since the interrupted attempt, so rebuild it from empty.
|
||||
await resetDirectoryReplaceStage(parent, endpoints, targetSftpId);
|
||||
}
|
||||
// Session setup and traversal may finish more children. Capture only
|
||||
// after resetting the stage/checkpoint: all these completions refer to
|
||||
// the discarded destination, while later completions remain valid.
|
||||
const completedAtRestart = new Map(sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((child) => child.parentTaskId === parent.id && child.status === "completed")
|
||||
.map((child) => [child.id, child]));
|
||||
const destRoot = resolveDirectoryResumeTargetRoot(parent);
|
||||
if (endpoints.isDownload) await ensureLocalDir(destRoot);
|
||||
if (targetSftpId) await ensureRemoteDir(targetSftpId, destRoot);
|
||||
@@ -1188,8 +1198,25 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
}
|
||||
if (targetSftpId) await ensureRemoteDir(targetSftpId, getParentPath(file.targetPath));
|
||||
|
||||
let countedCompleted = false;
|
||||
const onOwnerChanged = () => {
|
||||
if (countedCompleted) {
|
||||
countedCompleted = false;
|
||||
completedCount -= 1;
|
||||
failedCount += 1;
|
||||
}
|
||||
options?.onChildSuperseded?.(childId);
|
||||
};
|
||||
let childObservation: TransferOwnerObservation | undefined = sftpTransferCenterStore.observeTaskSettlement(
|
||||
childBase, resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined, onOwnerChanged,
|
||||
);
|
||||
const publishChild = (child: TransferTask) => {
|
||||
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
|
||||
if (options?.onChildUpdate?.(child, childObservation) === true) childObservation = undefined;
|
||||
};
|
||||
try {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
if (childObservation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before source validation");
|
||||
|
||||
const sourceType = endpoints.isUpload ? "local" as const : "sftp" as const;
|
||||
const targetType = endpoints.isDownload ? "local" as const : "sftp" as const;
|
||||
@@ -1206,6 +1233,7 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
const validationError = validateTransferResumeSource(childBase, {
|
||||
size: sourceStat.size,
|
||||
lastModified: sourceStat.lastModified,
|
||||
sizeKnown: sourceStat.sizeKnown,
|
||||
}, { allowSourceGrowth });
|
||||
const classified = classifyResumeSourceValidationError(validationError);
|
||||
if (classified.kind === "restart") {
|
||||
@@ -1217,8 +1245,7 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
sourceLastModified: sourceStat.lastModified,
|
||||
};
|
||||
} else if (classified.kind === "modified") {
|
||||
attentionCount += 1;
|
||||
options?.onChildUpdate?.({
|
||||
publishChild({
|
||||
...childBase,
|
||||
status: "attention",
|
||||
error: classified.message || validationError || "Source was modified",
|
||||
@@ -1227,6 +1254,7 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
phase: undefined,
|
||||
retryable: true,
|
||||
});
|
||||
attentionCount += 1;
|
||||
return;
|
||||
} else if (classified.kind === "fatal") {
|
||||
throw new Error(classified.message || validationError || "Resume validation failed");
|
||||
@@ -1236,56 +1264,71 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
// zero-byte plans (`||` would incorrectly promote to grown size).
|
||||
const plannedBytes = Number(childBase.totalBytes);
|
||||
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
|
||||
// Stat-less SCP sources report size as a placeholder 0; keep the
|
||||
// planned size rather than re-planning to a fake zero.
|
||||
const sourceSizeKnown = sourceStat.sizeKnown !== false && Number.isFinite(sourceStat.size);
|
||||
childBase = {
|
||||
...childBase,
|
||||
totalBytes: allowSourceGrowth
|
||||
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
|
||||
: (sourceStat.size || childBase.totalBytes),
|
||||
sourceLastModified: allowSourceGrowth
|
||||
&& hasPlannedBytes
|
||||
&& sourceStat.size > plannedBytes
|
||||
? (childBase.sourceLastModified ?? sourceStat.lastModified)
|
||||
: (sourceStat.lastModified ?? childBase.sourceLastModified),
|
||||
totalBytes: !sourceSizeKnown
|
||||
? childBase.totalBytes
|
||||
: allowSourceGrowth
|
||||
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
|
||||
: (sourceStat.size || childBase.totalBytes),
|
||||
sourceLastModified: !sourceSizeKnown
|
||||
? (sourceStat.lastModified ?? childBase.sourceLastModified)
|
||||
: allowSourceGrowth
|
||||
&& hasPlannedBytes
|
||||
&& sourceStat.size > plannedBytes
|
||||
? (childBase.sourceLastModified ?? sourceStat.lastModified)
|
||||
: (sourceStat.lastModified ?? childBase.sourceLastModified),
|
||||
};
|
||||
}
|
||||
|
||||
// Re-check abort after async stat before inserting a transferring child.
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => {
|
||||
// Publish only after admission, while identity changes are observed.
|
||||
options?.onChildUpdate?.(childBase);
|
||||
return bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
targetType,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceHostId: endpoints.sourceHost?.id,
|
||||
targetHostId: endpoints.targetHost?.id,
|
||||
totalBytes: Number.isFinite(childBase.totalBytes)
|
||||
? childBase.totalBytes
|
||||
: (file.size || undefined),
|
||||
resumable: parent.resumable !== false,
|
||||
checkpointBytes: childBase.checkpointBytes ?? 0,
|
||||
resumeStage: childBase.resumeStage,
|
||||
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
}, () => options?.shouldAbort?.() === true, pausedAtResume.get(childId),
|
||||
resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined,
|
||||
// Drop queued UI updates at the ownership change itself: waiting
|
||||
// for this invocation's reply may allow an intervening batch flush.
|
||||
onOwnerChanged,
|
||||
(observation) => { childObservation = observation; return true; }, childObservation);
|
||||
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
targetType,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceHostId: endpoints.sourceHost?.id,
|
||||
targetHostId: endpoints.targetHost?.id,
|
||||
totalBytes: Number.isFinite(childBase.totalBytes)
|
||||
? childBase.totalBytes
|
||||
: (file.size || undefined),
|
||||
resumable: parent.resumable !== false,
|
||||
checkpointBytes: childBase.checkpointBytes ?? 0,
|
||||
resumeStage: childBase.resumeStage,
|
||||
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
|
||||
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
completedCount += 1;
|
||||
options?.onChildUpdate?.({
|
||||
publishChild({
|
||||
...childBase,
|
||||
status: "completed",
|
||||
transferredBytes: Number.isFinite(childBase.totalBytes)
|
||||
@@ -1297,13 +1340,21 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
reconnectRequired: false,
|
||||
phase: undefined,
|
||||
});
|
||||
countedCompleted = true;
|
||||
completedCount += 1;
|
||||
bumpParentProgress(0);
|
||||
} catch (error) {
|
||||
if (options?.shouldAbort?.() || /cancelled|canceled/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
failedCount += 1;
|
||||
options?.onChildUpdate?.({
|
||||
// Another invocation owns this ID now. Report this walk's failure
|
||||
// without replacing the winner or recreating its compacted row.
|
||||
if (error instanceof TransferOwnerChangedError || childObservation?.hasIdentityConflict()) {
|
||||
options?.onChildSuperseded?.(childId);
|
||||
return;
|
||||
}
|
||||
publishChild({
|
||||
...childBase,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -1312,6 +1363,8 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
reconnectRequired: false,
|
||||
phase: undefined,
|
||||
});
|
||||
} finally {
|
||||
childObservation?.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1322,6 +1375,10 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
|
||||
if (result?.error) throw new Error(result.error);
|
||||
|
||||
// Commit deferred child outcomes before deciding success or promoting a stage.
|
||||
// Async connection cleanup must not outlive provisional completion evidence.
|
||||
options?.flushChildUpdates?.();
|
||||
|
||||
if (attentionCount > 0 && failedCount === 0 && completedCount + attentionCount >= totalFiles) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
|
||||
for (const closePanel of [false, true]) {
|
||||
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let finishListing!: () => void;
|
||||
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
|
||||
let listingStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
|
||||
let listCalls = 0;
|
||||
let maxCount = 0;
|
||||
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
|
||||
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => undefined,
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let rootId = "";
|
||||
let reconnects = 0;
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
|
||||
reconnects++;
|
||||
return { success: false, error: "unexpected reconnect" };
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "direct-runtime-owner", 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 () => {
|
||||
listCalls++; listingStarted(); await listingGate;
|
||||
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(ops);
|
||||
await act(async () => {
|
||||
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
|
||||
await started;
|
||||
});
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
|
||||
assert.ok(root); rootId = root.id;
|
||||
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
|
||||
await act(async () => { await ops!.pauseTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
|
||||
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
|
||||
await act(async () => { await ops!.resumeTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
|
||||
assert.equal(reconnects, 0);
|
||||
finishListing();
|
||||
await act(async () => { assert.equal(await running, "completed"); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
|
||||
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
|
||||
assert.equal(listCalls, 1);
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
|
||||
} finally {
|
||||
releaseTransferPauseTree(rootId, []);
|
||||
finishListing();
|
||||
await act(async () => { await running; renderer?.unmount(); });
|
||||
if (rootId) sftpTransferCenterStore.dismiss(rootId);
|
||||
unsubscribe();
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(null);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
|
||||
|
||||
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
|
||||
name: "f",
|
||||
size: 50_000,
|
||||
type: "file",
|
||||
lastModified: 0,
|
||||
sizeKnown: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const listedSize of [25_000, 100_000]) {
|
||||
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async (_id, path, encoding) => {
|
||||
assert.equal(path, "/tmp/f");
|
||||
assert.equal(encoding, "auto");
|
||||
return stat();
|
||||
},
|
||||
"sftp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
|
||||
assert.notEqual(snapshot.size, listedSize);
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale file entry replaced by a directory uses directory routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, type: "directory" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
|
||||
test("a stale directory entry replaced by a file uses file routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 12, type: "file" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
|
||||
});
|
||||
|
||||
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
|
||||
const rejected = [
|
||||
undefined,
|
||||
async () => null as unknown as SftpStatResult,
|
||||
async () => { throw new Error("File not found"); },
|
||||
async () => stat({ type: "symlink" }),
|
||||
async () => stat({ size: Number.NaN }),
|
||||
async () => stat({ size: -1 }),
|
||||
];
|
||||
for (const candidate of rejected) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, sizeKnown: false }),
|
||||
"scp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
|
||||
});
|
||||
|
||||
test("SCP links retain their resolved file or directory routing without using link size", async () => {
|
||||
for (const target of ["file", "directory"] as const) {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink", size: 8 }),
|
||||
"scp-1", "/tmp/link", "auto",
|
||||
async (_id, parent, encoding) => {
|
||||
assert.equal(parent, "/tmp");
|
||||
assert.equal(encoding, "auto");
|
||||
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
|
||||
},
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
|
||||
}
|
||||
});
|
||||
|
||||
test("SCP link whose current target cannot be confirmed stops safely", async () => {
|
||||
for (const entries of [
|
||||
[],
|
||||
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
|
||||
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink" }),
|
||||
"scp-1", "/tmp/link", "auto", async () => entries,
|
||||
),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("directory routing does not require a file byte count", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
|
||||
"sftp-1", "/tmp/d", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
|
||||
import type { RemoteFile } from "../../../domain/models/workspace";
|
||||
import { getFileName, getParentPath } from "./utils";
|
||||
|
||||
export type DownloadSourceSnapshot = {
|
||||
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
|
||||
size: number | undefined;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type StatSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<SftpStatResult>;
|
||||
type ListSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<RemoteFile[]>;
|
||||
|
||||
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
|
||||
|
||||
/** A stale listing must never supply the size or type of a new download. */
|
||||
export const resolveDownloadSourceSnapshot = async (
|
||||
statSftp: StatSftp | undefined,
|
||||
sftpId: string,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
listSftp?: ListSftp,
|
||||
): Promise<DownloadSourceSnapshot> => {
|
||||
if (!statSftp) throw sourceUnavailable();
|
||||
|
||||
let stat: SftpStatResult | null;
|
||||
try {
|
||||
stat = await statSftp(sftpId, sourcePath, encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
|
||||
if (!stat) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
if (stat.type === "directory") return { size: 0, isDirectory: true };
|
||||
if (stat.type === "symlink") {
|
||||
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
|
||||
// parent to resolve the current target kind rather than trusting the pane.
|
||||
if (!listSftp) throw sourceUnavailable();
|
||||
let entries: RemoteFile[];
|
||||
try {
|
||||
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
|
||||
if (current?.type !== "symlink"
|
||||
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
// The link's own size is never a valid file plan; SCP's wire header is.
|
||||
return { size: undefined, isDirectory: current.linkTarget === "directory" };
|
||||
}
|
||||
if (stat.type !== "file") throw sourceUnavailable();
|
||||
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
|
||||
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
|
||||
return { size: stat.size, isDirectory: false };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
@@ -64,3 +64,15 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("unwrapSftpIpcError strips the Electron IPC wrapper", () => {
|
||||
assert.equal(
|
||||
unwrapSftpIpcError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:delete': Error: Permission denied"),
|
||||
),
|
||||
"Permission denied",
|
||||
);
|
||||
assert.equal(unwrapSftpIpcError(new Error("Permission denied")), "Permission denied");
|
||||
assert.equal(unwrapSftpIpcError("channel closed"), "channel closed");
|
||||
});
|
||||
@@ -18,4 +18,13 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const SFTP_IPC_ERROR_PREFIX = /^Error invoking remote method 'netcatty:[^']+':\s*/i;
|
||||
|
||||
/** Drop Electron IPC wrapper so dialogs can show the server error. */
|
||||
export function unwrapSftpIpcError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? "");
|
||||
const unwrapped = raw.replace(SFTP_IPC_ERROR_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
||||
return unwrapped || raw;
|
||||
}
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -500,3 +500,23 @@ for (const newerResume of [false, true]) {
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
|
||||
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
|
||||
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
|
||||
registerTransferWalk(root.id);
|
||||
const { host, getTasks } = createHost([root, child], () => ({
|
||||
pauseTransfer: async () => ({ success: true }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
}));
|
||||
await softPauseTransfer(host, root.id);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(child.id), true);
|
||||
// Completion during soft-drain compacts this row before the user resumes.
|
||||
host.setTasks(getTasks().filter((task) => task.id !== child.id));
|
||||
const resumed = await softResumeTransfer(host, root.id);
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
import { createSftpDirectoryTraversalBudget } from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
|
||||
const modes = [
|
||||
{ name: "ordinary local upload", sourceLocal: true, targetLocal: false, sameConnection: false, bounded: false },
|
||||
{ name: "cross-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: false, bounded: false },
|
||||
{ name: "same-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: true, bounded: true },
|
||||
{ name: "remote download", sourceLocal: false, targetLocal: true, sameConnection: false, bounded: true },
|
||||
] as const;
|
||||
|
||||
for (const mode of modes) {
|
||||
for (const limit of ["entries", "directories"] as const) {
|
||||
test(`${mode.name} ${mode.bounded ? "enforces" : "does not inherit"} the ${limit} budget`, async () => {
|
||||
const previousGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpDirectoryTransferOps>;
|
||||
const listed: string[] = [];
|
||||
const root: TransferTask = {
|
||||
id: crypto.randomUUID(), fileName: "source", sourcePath: "/source", targetPath: "/target",
|
||||
sourceConnectionId: "source", targetConnectionId: mode.sameConnection ? "source" : "target",
|
||||
direction: mode.sourceLocal ? "upload" : "download", status: "transferring",
|
||||
totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: true,
|
||||
};
|
||||
const transfersRef = { current: [root] };
|
||||
const list = async (value: string): Promise<SftpFileEntry[]> => {
|
||||
listed.push(value);
|
||||
return value === "/source" ? ["one", "two"].map(name => ({
|
||||
name, type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "",
|
||||
})) : [];
|
||||
};
|
||||
netcattyBridge.get = () => ({
|
||||
mkdirLocal: async () => undefined,
|
||||
mkdirSftp: async () => undefined,
|
||||
realpathSftp: async (_id: string, value: string) => value,
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() {
|
||||
ops = useSftpDirectoryTransferOps({
|
||||
ownerId: root.id, cancelledTasksRef: { current: new Set() }, pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined, activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers: update => { transfersRef.current = typeof update === "function" ? update(transfersRef.current) : update; },
|
||||
listLocalFiles: list, listRemoteFiles: (_id, value) => list(value),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
// Three empty directories exercise real recursion without large fixtures or file streams.
|
||||
const budget = createSftpDirectoryTraversalBudget({
|
||||
maxEntries: limit === "entries" ? 1 : 10,
|
||||
maxDirectories: limit === "directories" ? 1 : 10,
|
||||
});
|
||||
const run = ops.transferDirectory(root, mode.sourceLocal ? null : "remote-source",
|
||||
mode.targetLocal ? null : "remote-target", mode.sourceLocal, mode.targetLocal,
|
||||
"auto", "auto", root.id, false, 0, false, undefined, budget);
|
||||
if (mode.bounded) {
|
||||
await assert.rejects(run, limit === "entries" ? /entry limit exceeded/ : /directory limit exceeded/);
|
||||
assert.deepEqual(listed, ["/source"]);
|
||||
} else {
|
||||
assert.equal(await run, 0);
|
||||
assert.deepEqual(listed, ["/source", "/source/one", "/source/two"]);
|
||||
assert.equal(budget.visitedDirectories, 0);
|
||||
assert.equal(budget.visitedEntries, 0);
|
||||
}
|
||||
assert.equal(budget.activeCanonicalDirectories.size, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
netcattyBridge.get = previousGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
startSftpFileDrag, takeLocalFileDragSources, getLocalFileDragSources,
|
||||
clearLocalFileDrag, clearLocalFileDragForPane, localDragPathKey, localFileDragMoveEffect,
|
||||
} from './localFileDrag';
|
||||
|
||||
function setup(t: test.TestContext) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true });
|
||||
const calls: { requestId: string; paths: string[] }[] = [];
|
||||
const cancels: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const remote: unknown[] = [];
|
||||
const bridge = {
|
||||
startLocalFileDrag: async (payload) => { calls.push(payload); return { started: true }; },
|
||||
cancelLocalFileDrag: (id) => cancels.push(id),
|
||||
getPathForFile: (file) => (file as unknown as { path: string }).path,
|
||||
} as unknown as NetcattyBridge;
|
||||
Object.defineProperty(window, "netcatty", { value: bridge, configurable: true });
|
||||
t.after(() => {
|
||||
clearLocalFileDrag();
|
||||
dom.window.close();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
const transfer = (paths: string[] = []) => ({
|
||||
types: ['Files'], files: paths.map((path) => ({ path })),
|
||||
effectAllowed: '', setData: (...args: string[]) => remote.push(args),
|
||||
}) as unknown as DataTransfer;
|
||||
const sources = [
|
||||
{ name: 'certificate.txt', sourcePath: '/local', sourceConnectionId: 'local', isDirectory: false },
|
||||
{ name: 'folder', sourcePath: '/another path', sourceConnectionId: 'local', isDirectory: true },
|
||||
];
|
||||
let prevented = 0;
|
||||
const start = (isLocal: boolean | undefined = true, selection = sources) => startSftpFileDrag({
|
||||
event: { dataTransfer: transfer(), preventDefault: () => { prevented++; } },
|
||||
paneId: 'pane', connection: { id: 'local', isLocal }, sources: selection, side: 'left',
|
||||
onRemoteDrag: (...args) => remote.push(args), onError: (error) => errors.push(error),
|
||||
});
|
||||
return { calls, cancels, errors, remote, transfer, sources, start, prevented: () => prevented, dom };
|
||||
}
|
||||
|
||||
test('local multi-selection retains individual tree paths and never exports plain text', async (t) => {
|
||||
const h = setup(t);
|
||||
h.start();
|
||||
assert.equal(h.prevented(), 1);
|
||||
assert.equal(h.remote.length, 0);
|
||||
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt', '/another path/folder']);
|
||||
const payload = h.transfer([...h.calls[0].paths].reverse());
|
||||
assert.equal(getLocalFileDragSources(payload)?.length, 2);
|
||||
assert.deepEqual(takeLocalFileDragSources(payload), h.sources.map((source) => ({ ...source, side: 'left' })));
|
||||
assert.equal(takeLocalFileDragSources(payload), null, 'a native gesture is consumed exactly once');
|
||||
assert.equal(getLocalFileDragSources(payload), null);
|
||||
});
|
||||
|
||||
test('native paths retain symlink-parent segments and match the returning internal drop', (t) => {
|
||||
const h = setup(t);
|
||||
const sources = [{ ...h.sources[0], sourcePath: '/local/link/.././folder' }];
|
||||
h.start(true, sources);
|
||||
assert.deepEqual(h.calls[0].paths, ['/local/link/.././folder/certificate.txt']);
|
||||
const payload = h.transfer(h.calls[0].paths);
|
||||
assert.deepEqual(takeLocalFileDragSources(payload), sources.map(source => ({ ...source, side: 'left' })));
|
||||
assert.equal(takeLocalFileDragSources(payload), null);
|
||||
});
|
||||
|
||||
test('remote origin preserves HTML payload and callbacks, including absent isLocal', (t) => {
|
||||
const h = setup(t);
|
||||
h.start(false);
|
||||
assert.equal(h.prevented(), 0);
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.deepEqual(h.remote, [['text/plain', 'certificate.txt\nfolder'], [h.sources, 'left']]);
|
||||
startSftpFileDrag({ event: { dataTransfer: h.transfer(), preventDefault: () => assert.fail('remote drag canceled') },
|
||||
paneId: 'remote', connection: { id: 'remote' }, sources: h.sources, side: 'right',
|
||||
onRemoteDrag: () => {}, onError: assert.fail });
|
||||
assert.equal(h.calls.length, 0);
|
||||
});
|
||||
|
||||
test('unrelated external files cannot reuse a previous local gesture', (t) => {
|
||||
const h = setup(t);
|
||||
h.start();
|
||||
assert.equal(takeLocalFileDragSources(h.transfer(['/different/certificate.txt'])), null);
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null);
|
||||
h.start();
|
||||
assert.equal(takeLocalFileDragSources(h.transfer(['/local/certificate.txt'])), null, 'partial selection is not an internal transfer');
|
||||
});
|
||||
|
||||
test('local failure cancels without text fallback and stale async errors cannot clear a newer gesture', async (t) => {
|
||||
const h = setup(t);
|
||||
let reject!: (error: Error) => void;
|
||||
netcattyBridge.require().startLocalFileDrag = () => new Promise((_resolve, rejectPromise) => { reject = rejectPromise; });
|
||||
h.start();
|
||||
const oldReject = reject;
|
||||
h.start();
|
||||
oldReject(new Error('obsolete'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(h.errors.length, 0);
|
||||
assert.ok(getLocalFileDragSources(h.transfer()));
|
||||
reject(new Error('file disappeared'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(h.errors, ['file disappeared']);
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null);
|
||||
assert.equal(h.remote.length, 0);
|
||||
});
|
||||
|
||||
test('native state is cleared on physical cancellation, external completion, connection disposal and new gestures', async (t) => {
|
||||
const h = setup(t);
|
||||
for (const event of [new h.dom.window.KeyboardEvent('keydown', { key: 'Escape' }),
|
||||
new h.dom.window.MouseEvent('mousemove', { buttons: 0 }), new h.dom.window.MouseEvent('mousedown'),
|
||||
new h.dom.window.Event('blur'), new h.dom.window.Event('pagehide')]) {
|
||||
h.start();
|
||||
await Promise.resolve();
|
||||
window.dispatchEvent(event);
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null, event.type);
|
||||
}
|
||||
h.start();
|
||||
clearLocalFileDragForPane('another-pane');
|
||||
assert.ok(getLocalFileDragSources(h.transfer()));
|
||||
clearLocalFileDragForPane('pane');
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null);
|
||||
h.start();
|
||||
h.start(false);
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null);
|
||||
});
|
||||
|
||||
test('invalid local selections and missing bridge are rejected; parent entry is excluded', (t) => {
|
||||
const h = setup(t);
|
||||
h.start(true, [{ ...h.sources[0], name: '..' }]);
|
||||
assert.equal(h.calls.length, 0);
|
||||
h.start(true, [{ ...h.sources[0], sourceConnectionId: 'remote' }]);
|
||||
assert.equal(h.calls.length, 0);
|
||||
h.start(true, [h.sources[0], { ...h.sources[0], name: '..' }]);
|
||||
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt']);
|
||||
netcattyBridge.require().startLocalFileDrag = undefined;
|
||||
h.start();
|
||||
assert.equal(h.errors.length, 3);
|
||||
assert.equal(h.remote.length, 0);
|
||||
});
|
||||
|
||||
test('path matching preserves POSIX whitespace, Unicode and backslashes, and accepts Windows separators', () => {
|
||||
assert.equal(localDragPathKey('C:\\Users\\david\\certificate.pem'), 'C:/Users/david/certificate.pem');
|
||||
assert.equal(localDragPathKey('\\\\server\\share\\folder'), '//server/share/folder');
|
||||
assert.equal(localDragPathKey('/local/é \\name '), '/local/é \\name ');
|
||||
assert.notEqual(localDragPathKey('/local/A'), localDragPathKey('/local/a'));
|
||||
});
|
||||
|
||||
|
||||
test('local move accepts the copy-only native transport on Windows/Linux without changing the operation', () => {
|
||||
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyLink' }), 'copy');
|
||||
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copy' }), 'copy');
|
||||
assert.equal(localFileDragMoveEffect({ effectAllowed: 'all' }), 'move');
|
||||
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyMove' }), 'move');
|
||||
});
|
||||
|
||||
test('native drop cleanup survives the browser microtask checkpoint before React receives the drop', async (t) => {
|
||||
const h = setup(t);
|
||||
h.start();
|
||||
window.dispatchEvent(new h.dom.window.Event('drop'));
|
||||
// Unlike dispatchEvent in jsdom, native browser events can drain microtasks
|
||||
// after the window capture callback, before the React root bubble callback.
|
||||
await Promise.resolve();
|
||||
assert.ok(getLocalFileDragSources(h.transfer()), 'capture must not erase native identity before React handles drop');
|
||||
assert.deepEqual(takeLocalFileDragSources(h.transfer(h.calls[0].paths)), h.sources.map(source => ({ ...source, side: 'left' })));
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null);
|
||||
|
||||
h.start();
|
||||
window.dispatchEvent(new h.dom.window.Event('drop'));
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
assert.equal(getLocalFileDragSources(h.transfer()), null, 'unhandled drops also release the gesture');
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
export interface LocalDragSource {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
side: "left" | "right";
|
||||
}
|
||||
|
||||
interface DragSession {
|
||||
requestId: string;
|
||||
paneId: string;
|
||||
connectionId: string;
|
||||
paths: string[];
|
||||
sources: LocalDragSource[];
|
||||
started?: boolean;
|
||||
}
|
||||
|
||||
// Windows separators can differ between the SFTP UI and Electron webUtils.
|
||||
// POSIX backslashes, whitespace, and case must remain significant.
|
||||
export function localDragPathKey(value: string): string {
|
||||
if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value)) {
|
||||
return value.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
return value.length > 1 ? value.replace(/\/$/, "") : value;
|
||||
}
|
||||
|
||||
class LocalFileDragState {
|
||||
session: DragSession | null = null;
|
||||
|
||||
clear(requestId?: string) {
|
||||
if (!requestId || this.session?.requestId === requestId) this.session = null;
|
||||
}
|
||||
|
||||
take(paths: string[]): LocalDragSource[] | null {
|
||||
const session = this.session;
|
||||
this.clear();
|
||||
if (!session || paths.length === 0 || paths.some((path) => !path)) return null;
|
||||
const expected = new Set(session.paths.map(localDragPathKey));
|
||||
const actual = new Set(paths.map(localDragPathKey));
|
||||
if (expected.size !== actual.size || [...expected].some((path) => !actual.has(path))) return null;
|
||||
return session.sources;
|
||||
}
|
||||
}
|
||||
|
||||
const state = new LocalFileDragState();
|
||||
let detach: (() => void) | undefined;
|
||||
|
||||
export function clearLocalFileDrag(requestId?: string) {
|
||||
if (requestId && state.session?.requestId !== requestId) return;
|
||||
const previous = state.session;
|
||||
state.clear();
|
||||
detach?.();
|
||||
detach = undefined;
|
||||
if (previous) netcattyBridge.get()?.cancelLocalFileDrag?.(previous.requestId);
|
||||
}
|
||||
|
||||
export function clearLocalFileDragForPane(paneId: string) {
|
||||
if (state.session?.paneId === paneId) clearLocalFileDrag();
|
||||
}
|
||||
|
||||
function observeGestureEnd() {
|
||||
let dropCleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Electron has no cross-platform native-drag completion callback. A canceled
|
||||
// HTML drag's dragend is not a native completion signal. Clear on drop and
|
||||
// on the next physical input/focus transition, including Escape and returning
|
||||
// after a drop outside the window. Never retain state into a new gesture.
|
||||
const clear = () => clearLocalFileDrag();
|
||||
const key = (event: KeyboardEvent) => { if (event.key === "Escape") clear(); };
|
||||
const move = (event: MouseEvent) => { if (state.session?.started && event.buttons === 0) clear(); };
|
||||
const drop = () => {
|
||||
const requestId = state.session?.requestId;
|
||||
// Native event dispatch can run microtasks BETWEEN capture and React's
|
||||
// bubble listener. Use a macrotask so the row/pane can consume the gesture.
|
||||
dropCleanupTimer = setTimeout(() => { if (requestId) clearLocalFileDrag(requestId); }, 0);
|
||||
};
|
||||
// preventDefault() on dragstart produces a synthetic mouseup in Chromium.
|
||||
// It must not cancel the native handoff. Observe later input instead.
|
||||
window.addEventListener("mousedown", clear, true);
|
||||
window.addEventListener("mousemove", move, true);
|
||||
window.addEventListener("keydown", key, true);
|
||||
window.addEventListener("blur", clear);
|
||||
window.addEventListener("pagehide", clear);
|
||||
window.addEventListener("drop", drop, true);
|
||||
detach = () => {
|
||||
if (dropCleanupTimer !== undefined) clearTimeout(dropCleanupTimer);
|
||||
window.removeEventListener("mousedown", clear, true);
|
||||
window.removeEventListener("mousemove", move, true);
|
||||
window.removeEventListener("keydown", key, true);
|
||||
window.removeEventListener("blur", clear);
|
||||
window.removeEventListener("pagehide", clear);
|
||||
window.removeEventListener("drop", drop, true);
|
||||
};
|
||||
}
|
||||
|
||||
type DragEventLike = Pick<DragEvent, "dataTransfer" | "preventDefault">;
|
||||
|
||||
export function startSftpFileDrag({ event, paneId, connection, sources, side, onRemoteDrag, onError }: {
|
||||
event: DragEventLike;
|
||||
paneId: string;
|
||||
connection?: { id: string; isLocal?: boolean } | null;
|
||||
sources: Omit<LocalDragSource, "side">[];
|
||||
side: "left" | "right";
|
||||
onRemoteDrag: (sources: Omit<LocalDragSource, "side">[], side: "left" | "right") => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
clearLocalFileDrag();
|
||||
if (connection?.isLocal !== true) {
|
||||
event.dataTransfer!.effectAllowed = "copyMove";
|
||||
event.dataTransfer!.setData("text/plain", sources.map((file) => file.name).join("\n"));
|
||||
onRemoteDrag(sources, side);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startLocalFileDrag) {
|
||||
onError("Native file drag is unavailable. Restart Netcatty after updating.");
|
||||
return;
|
||||
}
|
||||
const files = sources.filter((source) => source.name !== "..");
|
||||
if (!files.length || files.some((file) => !file.sourcePath || file.sourceConnectionId !== connection.id)) {
|
||||
onError("The local file selection is no longer available.");
|
||||
return;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const paths = [...new Set(files.map((file) => joinPath(file.sourcePath!, file.name)))];
|
||||
state.session = { requestId, paneId, connectionId: connection.id, paths, sources: files.map((file) => ({ ...file, side })) };
|
||||
observeGestureEnd();
|
||||
// Send during dragstart, without awaiting in the renderer. Do not interpret
|
||||
// the reply as a completed transfer or use it to clear an active gesture.
|
||||
try {
|
||||
void bridge.startLocalFileDrag({ requestId, paths }).then((result) => {
|
||||
if (result.started && state.session?.requestId === requestId) state.session.started = true;
|
||||
if (!result.started && state.session?.requestId === requestId) {
|
||||
clearLocalFileDrag(requestId);
|
||||
if (result.error) onError(result.error);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
if (state.session?.requestId !== requestId) return;
|
||||
clearLocalFileDrag(requestId);
|
||||
onError(error instanceof Error ? error.message : "Unable to drag local files");
|
||||
});
|
||||
} catch (error) {
|
||||
clearLocalFileDrag(requestId);
|
||||
onError(error instanceof Error ? error.message : "Unable to drag local files");
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
|
||||
return dataTransfer.types.includes("Files") ? state.session?.sources ?? null : null;
|
||||
}
|
||||
|
||||
export function localFileDragMoveEffect(dataTransfer: Pick<DataTransfer, "effectAllowed">): "copy" | "move" {
|
||||
// Electron's Windows/Linux native source advertises COPY | LINK, not MOVE.
|
||||
// Negotiate an allowed transport effect; our existing same-pane callback is
|
||||
// responsible for the actual local move (the OS must not delete originals).
|
||||
return ["all", "copyMove", "linkMove", "move"].includes(dataTransfer.effectAllowed) ? "move" : "copy";
|
||||
}
|
||||
|
||||
export function takeLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
|
||||
if (!state.session) return null;
|
||||
const bridge = netcattyBridge.get();
|
||||
const session = state.session;
|
||||
try {
|
||||
return state.take(Array.from(dataTransfer.files).map((file) => bridge?.getPathForFile?.(file) ?? ""));
|
||||
} catch {
|
||||
state.clear();
|
||||
return null;
|
||||
} finally {
|
||||
detach?.();
|
||||
detach = undefined;
|
||||
bridge?.cancelLocalFileDrag?.(session.requestId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { createEmptyPane } from "./types";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
for (const action of ["merge", "replace", "duplicate"] as const) {
|
||||
test(`same-directory ${action} preserves source links through the real transfer hook`, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".same-directory-copy-"));
|
||||
const ownerId = `self-copy-${action}-${crypto.randomUUID()}`;
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let writes = 0;
|
||||
let listings = 0;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
||||
const names = ["one", "two"];
|
||||
for (const name of names) {
|
||||
await fs.mkdir(path.join(dir, name));
|
||||
await fs.writeFile(path.join(dir, name, "file"), "original data");
|
||||
await fs.symlink("file", path.join(dir, name, "link"));
|
||||
}
|
||||
const pane = createEmptyPane("self-copy-pane");
|
||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
||||
const statPath = async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
||||
mkdirLocal: async (value: string) => { writes++; await fs.mkdir(value, { recursive: true }); },
|
||||
deleteLocalFile: async (value: string) => { writes++; await fs.rm(value, { recursive: true, force: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { writes++; await fs.rename(source, target); },
|
||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
||||
writes++;
|
||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listRemoteFiles: async () => [],
|
||||
listLocalFiles: async (value: string) => {
|
||||
listings++;
|
||||
return Promise.all((await fs.readdir(value)).map(async name => {
|
||||
const stat = await fs.lstat(path.join(value, name));
|
||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
||||
}));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => { await ops.startTransfer(names.map(name => ({ name, isDirectory: true })), "left", "left"); });
|
||||
assert.equal(ops.conflicts.length, 2, JSON.stringify(sftpTransferCenterStore.getOwnerTasks(ownerId)));
|
||||
const firstId = ops.conflicts[0].transferId;
|
||||
// Apply to all exercises shared batch resolution rather than just one row.
|
||||
await act(async () => { await ops.resolveConflict(firstId, action, true); });
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
for (const name of names) {
|
||||
assert.equal(await fs.readlink(path.join(dir, name, "link")), "file");
|
||||
assert.equal(await fs.readFile(path.join(dir, name, "file"), "utf8"), "original data");
|
||||
}
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
assert.equal(roots.length, 2);
|
||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
||||
if (action === "duplicate") {
|
||||
assert.ok(writes > 0);
|
||||
assert.ok(listings > 0);
|
||||
for (const name of names) assert.equal(await fs.readFile(path.join(dir, `${name} (copy)`, "file"), "utf8"), "original data");
|
||||
} else {
|
||||
assert.equal(writes, 0, "self merge/replace must never write or unlink anything");
|
||||
assert.equal(listings, 0, "self merge/replace must not walk the source tree");
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
getWindowsUncRoot,
|
||||
isSameSftpPath,
|
||||
isSftpDescendantPath,
|
||||
isWindowsPath,
|
||||
joinPath,
|
||||
} from "./utils";
|
||||
|
||||
export type SamePanePasteAction = "allow" | "block-same-folder" | "block-into-source";
|
||||
|
||||
export interface SamePanePasteFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/** Filesystem identity of a directory (stat dev/ino). */
|
||||
export interface SamePanePasteIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexically resolve "." / ".." segments and repeated separators so equivalent
|
||||
* spellings of the same directory (e.g. /home/user/., /home//user) compare
|
||||
* equal in the same-pane paste guards. Purely string-level: it never touches
|
||||
* the server, and it is only used to make the guards stricter, never to build
|
||||
* transfer paths.
|
||||
*/
|
||||
const canonicalizeSftpPath = (path: string): string => {
|
||||
const isWindows = isWindowsPath(path);
|
||||
const separator = isWindows ? "\\" : "/";
|
||||
const unified = isWindows ? path.replace(/\//g, "\\") : path;
|
||||
|
||||
let root = "";
|
||||
let rest = unified;
|
||||
if (isWindows) {
|
||||
const uncRoot = getWindowsUncRoot(unified, { acceptForwardSlashUnc: true });
|
||||
const driveRoot = unified.match(/^[A-Za-z]:\\/)?.[0];
|
||||
if (uncRoot) {
|
||||
root = uncRoot;
|
||||
rest = unified.slice(uncRoot.length);
|
||||
} else if (driveRoot) {
|
||||
root = driveRoot;
|
||||
rest = unified.slice(driveRoot.length);
|
||||
}
|
||||
} else if (unified.startsWith("/")) {
|
||||
// Collapse a leading "//" to "/" for comparisons: on ordinary POSIX
|
||||
// filesystems "//home/user" names the same directory as "/home/user", and
|
||||
// treating it as a distinct root would let a same-pane cut reach the
|
||||
// replace/delete flow. This helper never builds transfer paths, so the
|
||||
// guards stay strict either way.
|
||||
root = "/";
|
||||
rest = unified.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const segment of rest.split(separator)) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(segment);
|
||||
}
|
||||
|
||||
// UNC roots come back without a trailing separator, so re-insert one before
|
||||
// joining the segments; without it \\server\share\docs would collapse to
|
||||
// \\server\sharedocs and collide with an unrelated share's path.
|
||||
if (root) {
|
||||
return root.endsWith(separator)
|
||||
? root + parts.join(separator)
|
||||
: root + separator + parts.join(separator);
|
||||
}
|
||||
return parts.join(separator) || ".";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether an internal SFTP paste that targets the same connection as the
|
||||
* clipboard source can proceed. Copying files into their own source folder is
|
||||
* allowed (the conflict dialog handles same-name collisions), but pasting a
|
||||
* clipboard directory into itself or one of its descendants is blocked for both
|
||||
* copy and cut: the transfer creates the destination before listing the source,
|
||||
* so the fresh copy would be rediscovered and nested until the traversal limit.
|
||||
* For cuts, pasting into the clipboard's source folder is also blocked because
|
||||
* the post-move source delete would remove the freshly moved items. Note that
|
||||
* `sourcePath` is the folder containing every clipboard item, so the per-item
|
||||
* checks must join it with each selected entry's name.
|
||||
*
|
||||
* Lexical canonicalization alone cannot see through symlinks: when the
|
||||
* destination reaches the copied directory through an alias (e.g.
|
||||
* `/a/link -> /a/docs/sub`), a purely string-level comparison would allow the
|
||||
* paste and the recursive transfer would rediscover its own output. When
|
||||
* `resolvePath` is provided (SFTP realpath for remote connections, the local
|
||||
* realpath bridge for local panes), both sides are resolved through the
|
||||
* filesystem before comparing. If that resolution fails, the guard fails
|
||||
* closed and blocks the paste rather than risking a runaway transfer.
|
||||
*
|
||||
* realpath also cannot see through bind mounts: two mount paths for the same
|
||||
* directory resolve to different strings. When `statIdentity` is provided
|
||||
* (local panes only — remote SFTP stats carry no dev/ino), both sides' stat
|
||||
* identities are compared as a final check; equal identities block the paste.
|
||||
* The check only ever blocks on a positive identity match — an unavailable
|
||||
* stat falls through to the symlink guards above rather than blocking
|
||||
* unrelated pastes on transient stat failures.
|
||||
*/
|
||||
export const resolveSamePanePasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
/** Optional filesystem resolver (e.g. realpath). Throws → unresolvable. */
|
||||
resolvePath?: (path: string) => Promise<string>;
|
||||
/** Optional stat identity provider (dev/ino). Returns null → unknown. */
|
||||
statIdentity?: (path: string) => Promise<SamePanePasteIdentity | null>;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const { resolvePath, statIdentity } = params;
|
||||
|
||||
// Canonicalize first so equivalent spellings of the same directory (dot
|
||||
// segments, repeated separators) are still caught by the guards below, then
|
||||
// resolve through the filesystem when a resolver is available so symlink
|
||||
// aliases compare equal to their real targets. Returns null when resolution
|
||||
// fails, which callers treat as fail-closed.
|
||||
const canonicalizeForComparison = async (path: string): Promise<string | null> => {
|
||||
const lexical = canonicalizeSftpPath(path);
|
||||
if (!resolvePath) return lexical;
|
||||
try {
|
||||
return canonicalizeSftpPath(await resolvePath(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [targetPath, sourcePath] = await Promise.all([
|
||||
canonicalizeForComparison(params.targetPath),
|
||||
canonicalizeForComparison(params.sourcePath),
|
||||
]);
|
||||
// A files-only copy has no nesting hazard, so an unresolvable path only
|
||||
// blocks pastes that could actually recurse (directory items, or a cut).
|
||||
const failClosed = !targetPath || !sourcePath;
|
||||
if (failClosed && (params.operation === "cut" || params.files.some((file) => file.isDirectory))) {
|
||||
return "block-into-source";
|
||||
}
|
||||
if (failClosed) return "allow";
|
||||
|
||||
if (params.operation === "cut" && isSameSftpPath(targetPath, sourcePath)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
|
||||
// Bind-mount alias detection: realpath above maps each mount path to itself,
|
||||
// so a cut (whose post-transfer source delete would destroy the only copy)
|
||||
// must additionally compare filesystem identities. A positive match means
|
||||
// both paths name the same directory. dev/ino are only trusted when at least
|
||||
// one side is non-zero — Windows stats report meaningless 0 identities, and
|
||||
// the local bridge omits them there.
|
||||
const identityOf = async (path: string): Promise<SamePanePasteIdentity | null> => {
|
||||
if (!statIdentity) return null;
|
||||
try {
|
||||
const identity = await statIdentity(path);
|
||||
if (!identity || identity.dev === undefined || identity.ino === undefined) return null;
|
||||
if (identity.dev === 0 && identity.ino === 0) return null;
|
||||
return identity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const sameIdentity = (a: SamePanePasteIdentity, b: SamePanePasteIdentity): boolean =>
|
||||
a.dev === b.dev && a.ino === b.ino;
|
||||
|
||||
if (params.operation === "cut") {
|
||||
const [targetIdentity, sourceIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(params.sourcePath),
|
||||
]);
|
||||
if (targetIdentity && sourceIdentity && sameIdentity(targetIdentity, sourceIdentity)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of params.files) {
|
||||
if (!file.isDirectory) continue;
|
||||
const itemPath = await canonicalizeForComparison(joinPath(params.sourcePath, file.name));
|
||||
if (!itemPath) return "block-into-source";
|
||||
if (
|
||||
isSameSftpPath(targetPath, itemPath)
|
||||
|| isSftpDescendantPath(targetPath, itemPath)
|
||||
) {
|
||||
return "block-into-source";
|
||||
}
|
||||
// A bind-mount alias of the directory item itself also names the paste
|
||||
// destination, for both copy (recursive rediscovery) and cut (source
|
||||
// delete would remove the freshly pasted entry).
|
||||
if (statIdentity) {
|
||||
const [targetIdentity, itemIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(joinPath(params.sourcePath, file.name)),
|
||||
]);
|
||||
if (targetIdentity && itemIdentity && sameIdentity(targetIdentity, itemIdentity)) {
|
||||
return "block-into-source";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "allow";
|
||||
};
|
||||
|
||||
/** Application boundary for filesystem checks used by every internal paste entry. */
|
||||
export const resolveSameConnectionPasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
isLocal: boolean;
|
||||
sftpId: string | null;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let resolvePath: ((path: string) => Promise<string>) | undefined;
|
||||
let statIdentity: ((path: string) => Promise<SamePanePasteIdentity | null>) | undefined;
|
||||
if (params.isLocal) {
|
||||
if (bridge?.realpathLocal) resolvePath = (path) => bridge.realpathLocal!(path);
|
||||
if (bridge?.statLocal) {
|
||||
statIdentity = async (path) => {
|
||||
const stat = await bridge.statLocal!(path);
|
||||
if (stat.dev === undefined || stat.ino === undefined) return null;
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
};
|
||||
}
|
||||
} else if (params.sftpId && bridge?.realpathSftp) {
|
||||
const sftpId = params.sftpId;
|
||||
resolvePath = (path) => bridge.realpathSftp!(sftpId, path);
|
||||
}
|
||||
return resolveSamePanePasteAction({ ...params, resolvePath, statIdentity });
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
|
||||
test("bind-mounted parents cannot cause replacement to delete the source link", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
});
|
||||
netcattyBridge.get = () => ({
|
||||
// Bind mounts retain different realpaths but expose the same parent identity.
|
||||
realpathLocal: async (value: string) => value,
|
||||
statLocal: async (value: string) => ({
|
||||
type: "directory", size: 0, lastModified: 0, dev: 42,
|
||||
ino: value === "/a" || value === "/mnt/alias" ? 7 : 8,
|
||||
}),
|
||||
deleteLocalFile: async () => { deletes++; },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: "/a/link", targetPath: "/mnt/alias/link", fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
await assert.rejects(ops.deleteTargetPath(task, pane, null, "auto", "symlink"), /Choose Duplicate or Skip/);
|
||||
assert.equal(deletes, 0);
|
||||
assert.equal(await ops.isSameSourceEntry(task, pane, null, "auto"), true);
|
||||
// Different entries and genuinely different parents must remain replaceable.
|
||||
for (const targetPath of ["/mnt/alias/another-link", "/other/link"]) {
|
||||
const other = { ...task, targetPath };
|
||||
assert.equal(await ops.isSameSourceEntry(other, pane, null, "auto"), false);
|
||||
await ops.deleteTargetPath(other, pane, null, "auto", "symlink");
|
||||
}
|
||||
assert.equal(deletes, 2);
|
||||
});
|
||||
|
||||
test("same-pane link replacement preserves the source, including aliased parent paths", async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".pr3318-links-"));
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalGet = netcattyBridge.get;
|
||||
let renderer: ReactTestRenderer;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.join(dir, "source"));
|
||||
await fs.mkdir(path.join(dir, "other"));
|
||||
await fs.writeFile(path.join(dir, "source", "file"), "keep original contents");
|
||||
await fs.symlink("file", path.join(dir, "source", "link"));
|
||||
await fs.symlink("source", path.join(dir, "alias"));
|
||||
netcattyBridge.get = () => ({
|
||||
realpathLocal: fs.realpath,
|
||||
lstatLocal: async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
},
|
||||
deleteLocalFile: async (value: string) => { deletes++; await fs.unlink(value); },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: path.join(dir, "source", "link"),
|
||||
targetPath: path.join(dir, "source", "link"),
|
||||
fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
for (const parent of ["source", "alias", "source/."]) {
|
||||
await assert.rejects(ops.deleteTargetPath(
|
||||
{ ...task, targetPath: `${dir}/${parent}/link` }, pane, null, "auto", "symlink",
|
||||
), /Choose Duplicate or Skip/);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
assert.equal(await fs.readFile(task.sourcePath, "utf8"), "keep original contents");
|
||||
}
|
||||
assert.equal(deletes, 0);
|
||||
const duplicate = await ops.getDuplicateTarget(task, pane, null, "auto");
|
||||
assert.equal(duplicate.fileName, "link (copy)");
|
||||
await fs.symlink("../source/file", path.join(dir, "other", "link"));
|
||||
await ops.deleteTargetPath({ ...task, targetPath: path.join(dir, "other", "link") }, pane, null, "auto", "symlink");
|
||||
assert.equal(deletes, 1);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models"
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { getFileName, getParentPath, isSameSftpPath, joinPath } from "./utils";
|
||||
|
||||
export function useSftpTransferConflictOps() {
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
@@ -84,6 +84,42 @@ export function useSftpTransferConflictOps() {
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const isSameSourceEntry = useCallback(async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<boolean> => {
|
||||
if (!targetPane.connection || task.sourceConnectionId !== task.targetConnectionId) return false;
|
||||
if (isSameSftpPath(task.sourcePath, task.targetPath)) return true;
|
||||
const bridge = netcattyBridge.get();
|
||||
const resolve = targetPane.connection.isLocal
|
||||
? bridge?.realpathLocal
|
||||
: targetSftpId && bridge?.realpathSftp
|
||||
? (value: string) => bridge.realpathSftp!(targetSftpId, value, targetEncoding)
|
||||
: undefined;
|
||||
const [sourceParent, targetParent] = await Promise.all(
|
||||
[getParentPath(task.sourcePath), getParentPath(task.targetPath)].map(
|
||||
(value) => resolve ? resolve(value) : Promise.resolve(value),
|
||||
),
|
||||
);
|
||||
if (isSameSftpPath(
|
||||
joinPath(sourceParent, getFileName(task.sourcePath)),
|
||||
joinPath(targetParent, getFileName(task.targetPath)),
|
||||
)) return true;
|
||||
// Bind mounts keep distinct realpaths while naming the same directory.
|
||||
// Compare parents so a symlink is identified as an entry, not its referent.
|
||||
if (targetPane.connection.isLocal && bridge?.statLocal
|
||||
&& getFileName(task.sourcePath) === getFileName(task.targetPath)) {
|
||||
const [sourceStat, targetStat] = await Promise.all([
|
||||
bridge.statLocal(sourceParent), bridge.statLocal(targetParent),
|
||||
]);
|
||||
return sourceStat.dev !== undefined && sourceStat.ino !== undefined
|
||||
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
@@ -93,6 +129,11 @@ export function useSftpTransferConflictOps() {
|
||||
expectedType?: "file" | "directory" | "symlink",
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
// Replace unlinks symlinks before copying. Never unlink the source entry
|
||||
// when a clipboard copy is pasted back into its own directory.
|
||||
if (expectedType === "symlink" && await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding)) {
|
||||
throw new Error("Cannot replace the source link with itself. Choose Duplicate or Skip.");
|
||||
}
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
@@ -104,9 +145,9 @@ export function useSftpTransferConflictOps() {
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
|
||||
},
|
||||
[],
|
||||
[isSameSourceEntry],
|
||||
);
|
||||
|
||||
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry };
|
||||
}
|
||||
@@ -334,7 +334,8 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("live folder settles a superseded child whose completed row was compacted", async () => {
|
||||
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");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
@@ -355,10 +356,18 @@ test("live folder settles a superseded child whose completed row was compacted",
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
|
||||
// The winning invocation has already completed; history compaction drops its row.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
|
||||
});
|
||||
if (ownerChange !== "same") {
|
||||
const current = sftpTransferCenterStore.getTask(childId)!;
|
||||
sftpTransferCenterStore.admitTaskRun({ ...current, directoryEntryIdentity: "b".repeat(64) });
|
||||
}
|
||||
if (ownerChange !== "changed-active") {
|
||||
// The winning invocation completed; history compaction drops its row.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
|
||||
});
|
||||
}
|
||||
tasks = [...sftpTransferCenterStore.getSnapshot().tasks];
|
||||
transfersRef.current = tasks;
|
||||
return { superseded: true };
|
||||
},
|
||||
} };
|
||||
@@ -387,10 +396,15 @@ test("live folder settles a superseded child whose completed row was compacted",
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
|
||||
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
if (ownerChange === "changed-active") {
|
||||
assert.equal(tasks.find((task) => task.id === childId)?.status, "transferring");
|
||||
assert.equal(tasks.find((task) => task.id === childId)?.directoryEntryIdentity, "b".repeat(64));
|
||||
} else {
|
||||
assert.equal(tasks.find((task) => task.id === childId), undefined, "old waiter must not reinsert a compacted child");
|
||||
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
|
||||
assert.equal(result, 0);
|
||||
assert.equal(result, ownerChange === "same" ? 0 : 1);
|
||||
} finally {
|
||||
cancelledTasksRef.current.add(root.id);
|
||||
await running?.catch(() => {});
|
||||
@@ -404,3 +418,4 @@ test("live folder settles a superseded child whose completed row was compacted",
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { runTransferAndWaitForOwner, TransferOwnerChangedError } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
@@ -118,6 +118,10 @@ async function tryStatTransferPath(
|
||||
if (!sftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
|
||||
if (!stat || stat.type === "directory") return null;
|
||||
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
|
||||
// Treat that as missing metadata so skip-unchanged is disabled rather than
|
||||
// comparing a fake zero against the other endpoint (Codex P1).
|
||||
if (stat.sizeKnown === false) return null;
|
||||
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
|
||||
} catch {
|
||||
return null;
|
||||
@@ -558,11 +562,20 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
// Only the newly supported same-pane local copy needs this fallback.
|
||||
// Preserve existing large local uploads and cross-pane copies.
|
||||
const boundLocalTraversal = sourceIsLocal && targetIsLocal
|
||||
&& task.sourceConnectionId === task.targetConnectionId;
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
// Keep the current remote ancestor active through child discovery.
|
||||
try {
|
||||
if (!sourceIsLocal && sourceSftpId) {
|
||||
// Bound same-pane local copies that rediscover their own output through
|
||||
// filesystem aliases not detected by the paste guard.
|
||||
if (boundLocalTraversal) {
|
||||
claimedCanonicalPath = claimSftpDirectoryVisit(traversal, task.sourcePath);
|
||||
if (!claimedCanonicalPath) return totalErrors;
|
||||
} else if (!sourceIsLocal && sourceSftpId) {
|
||||
const bridge = netcattyBridge.get();
|
||||
const canonicalPath = await bridge?.realpathSftp?.(sourceSftpId, task.sourcePath, sourceEncoding)
|
||||
.catch(() => task.sourcePath) ?? task.sourcePath;
|
||||
@@ -611,7 +624,7 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
if (!sourceIsLocal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
if (!sourceIsLocal || boundLocalTraversal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
// Separate directories from files.
|
||||
// Symlink directories are only followed when followSymlinks is true
|
||||
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
|
||||
@@ -840,7 +853,9 @@ export function useSftpDirectoryTransferOps({
|
||||
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
|
||||
return { ...row, speed: 0 };
|
||||
}
|
||||
return { ...row, transferredBytes: row.transferredBytes + 1 };
|
||||
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
|
||||
return { ...row, transferredBytes: completed };
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -924,6 +939,12 @@ export function useSftpDirectoryTransferOps({
|
||||
|| parentRow.status === "pausing"
|
||||
|| isPauseLatched(rootTaskId)
|
||||
);
|
||||
// Background completion may have already compacted this child and
|
||||
// advanced the checkpoint. Count retained + compacted completions
|
||||
// instead of incrementing the same file again when invoke settles.
|
||||
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ prev.filter((row) => row.parentTaskId === rootTaskId
|
||||
&& (row.status === "completed" || row.id === fileId)).length;
|
||||
const updated = prev.map((t) => {
|
||||
if (t.id === fileId) {
|
||||
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
|
||||
@@ -934,7 +955,7 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: t.transferredBytes + 1,
|
||||
transferredBytes: completed,
|
||||
speed: t.speed,
|
||||
};
|
||||
}
|
||||
@@ -961,6 +982,10 @@ export function useSftpDirectoryTransferOps({
|
||||
errors.push(err instanceof Error ? err : new Error(message));
|
||||
return;
|
||||
}
|
||||
if (err instanceof TransferOwnerChangedError) {
|
||||
errors.push(err);
|
||||
return;
|
||||
}
|
||||
// Mark child as failed
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
const pausedIds = new Set<string>();
|
||||
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
|
||||
// A completed child's history row can disappear while its worker still waits
|
||||
// for the folder to resume. Keep the paused tree independent of UI history.
|
||||
const pausedChildren = new Map<string, Set<string>>();
|
||||
|
||||
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
|
||||
let barrier = barriers.get(taskId);
|
||||
@@ -45,11 +48,18 @@ export function releaseTransferPause(taskId: string): void {
|
||||
}
|
||||
|
||||
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
|
||||
pausedChildren.delete(rootTaskId);
|
||||
releaseTransferPause(rootTaskId);
|
||||
for (const id of childIds) releaseTransferPause(id);
|
||||
for (const id of children) releaseTransferPause(id);
|
||||
}
|
||||
|
||||
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
if (childIds.length > 0) {
|
||||
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
|
||||
for (const id of childIds) children.add(id);
|
||||
pausedChildren.set(rootTaskId, children);
|
||||
}
|
||||
latchTransferPause(rootTaskId);
|
||||
for (const id of childIds) latchTransferPause(id);
|
||||
}
|
||||
@@ -77,6 +87,7 @@ export function resetTransferPauseLatchesForTests(): void {
|
||||
for (const id of ids) releaseTransferPause(id);
|
||||
pausedIds.clear();
|
||||
barriers.clear();
|
||||
pausedChildren.clear();
|
||||
}
|
||||
|
||||
export function listTransferPauseLatchesForTests(): string[] {
|
||||
|
||||
@@ -169,3 +169,284 @@ test("fresh directory recovery authorizes only an unchanged retained pause under
|
||||
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
|
||||
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
|
||||
});
|
||||
|
||||
for (const compactNewOwner of [false, true]) {
|
||||
test(`superseded waiter rejects a changed identity even after new owner compaction: ${compactNewOwner}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `changed-owner-${compactNewOwner}`, parentTaskId: `changed-root-${compactNewOwner}` };
|
||||
store.upsertTasks([{ ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true }, task]);
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => {
|
||||
const changed = { ...task, directoryEntryIdentity: "b".repeat(64) };
|
||||
assert.equal(store.admitTaskRun(changed), "ready");
|
||||
if (compactNewOwner) {
|
||||
store.patchTask(changed.id, { status: "completed" });
|
||||
assert.equal(store.getTask(changed.id), undefined);
|
||||
}
|
||||
return { superseded: true };
|
||||
}, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await settled;
|
||||
store.patchTask(task.parentTaskId, { status: "completed" });
|
||||
store.dismiss(task.parentTaskId);
|
||||
});
|
||||
assert.match(String(await Promise.race([
|
||||
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
])), /identity changed/i);
|
||||
});
|
||||
}
|
||||
|
||||
test("destination rebuild restarts only the captured completion and preserves newer controls", async (t) => {
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), parentTaskId: undefined, status: "completed" as const, checkpointBytes: 10 };
|
||||
store.upsertTasks([task]);
|
||||
const captured = store.getTask(task.id)!;
|
||||
const restart = { ...task, status: "transferring" as const, checkpointBytes: 0, transferredBytes: 0 };
|
||||
latchTransferPause(task.id);
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
assert.equal(store.admitTaskRun(restart, undefined, captured), "paused");
|
||||
resetTransferPauseLatchesForTests();
|
||||
assert.equal(store.admitTaskRun(restart, undefined, captured), "ready");
|
||||
assert.equal(store.getTask(task.id)?.checkpointBytes, 0);
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
assert.equal(store.admitTaskRun(restart, undefined, captured), "completed", "new completion must not be restarted");
|
||||
store.patchTask(task.id, { status: "cancelled" });
|
||||
assert.equal(store.admitTaskRun(restart, undefined, captured), "cancelled");
|
||||
});
|
||||
|
||||
test("destination rebuild waiting on pause reuses a newer compacted completion", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "restart-wait-child", parentTaskId: "restart-wait-root", directoryEntryIndex: 1 };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
store.upsertTasks([root, { ...task, status: "completed" }]);
|
||||
const captured = store.getTask(task.id)!;
|
||||
latchTransferPause(task.id);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, undefined, captured);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
resetTransferPauseLatchesForTests();
|
||||
await settled;
|
||||
store.patchTask(root.id, { status: "completed" });
|
||||
store.dismiss(root.id);
|
||||
});
|
||||
// An unrelated lifecycle emit must not pin the old completion in the observation.
|
||||
store.patchTask(root.id, { reconnectRequired: true });
|
||||
assert.equal(await Promise.race([
|
||||
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 230)),
|
||||
]), "still-waiting", "old completion cannot finish recovery while a new transfer is still needed");
|
||||
assert.equal(starts, 0);
|
||||
resetTransferPauseLatchesForTests();
|
||||
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||||
store.upsertTasks([{ ...task, id: "restart-wait-first", directoryEntryIndex: 0, status: "completed" }]);
|
||||
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
|
||||
assert.equal(store.getTask(task.id), undefined);
|
||||
assert.equal(await settled, "completed");
|
||||
assert.equal(starts, 0, "new completion after stage rebuild must be reused even when its row was compacted");
|
||||
});
|
||||
|
||||
test("paused admission cannot reclaim an identity replaced while it waited", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "paused-displaced", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "paused" }]);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await settled;
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
store.dismiss(task.id);
|
||||
});
|
||||
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
assert.match(await settled, /identity changed/i);
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
|
||||
});
|
||||
|
||||
for (const identityChangesDuringWait of [false, true]) {
|
||||
test(`paused recovery distinguishes initial stale identity from later ownership: changed=${identityChangesDuringWait}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: `paused-origin-${identityChangesDuringWait}`, parentTaskId: `paused-origin-root-${identityChangesDuringWait}` };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
store.upsertTasks([root, {
|
||||
...task, status: identityChangesDuringWait ? "failed" : "paused",
|
||||
directoryEntryIdentity: identityChangesDuringWait ? task.directoryEntryIdentity : "b".repeat(64),
|
||||
}]);
|
||||
const initialPause = store.getTask(task.id)!;
|
||||
latchTransferPause(task.id);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
let conflicts = 0;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort,
|
||||
identityChangesDuringWait ? undefined : initialPause, undefined, () => { conflicts += 1; });
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
resetTransferPauseLatchesForTests();
|
||||
await settled;
|
||||
store.patchTask(root.id, { status: "completed" });
|
||||
store.dismiss(root.id);
|
||||
});
|
||||
// This captures the prior failed result, or republishes the unchanged
|
||||
// initial old identity. Neither is evidence of a replacement by itself.
|
||||
store.patchTask(root.id, { reconnectRequired: true });
|
||||
resetTransferPauseLatchesForTests();
|
||||
if (identityChangesDuringWait) {
|
||||
store.patchTask(task.id, { status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 });
|
||||
}
|
||||
const outcome = await settled;
|
||||
if (identityChangesDuringWait) {
|
||||
assert.match(outcome, /identity changed/i);
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(conflicts, 1);
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
|
||||
} else {
|
||||
assert.equal(outcome, "completed");
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(conflicts, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("paused retry observes newer same-identity completion after an old failure", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "failed-then-completed", parentTaskId: "failed-then-completed-root" };
|
||||
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
|
||||
store.upsertTasks([root, { ...task, status: "failed" }]);
|
||||
latchTransferPause(task.id);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
resetTransferPauseLatchesForTests();
|
||||
await settled;
|
||||
store.patchTask(root.id, { status: "completed" });
|
||||
store.dismiss(root.id);
|
||||
});
|
||||
store.patchTask(root.id, { reconnectRequired: true });
|
||||
resetTransferPauseLatchesForTests();
|
||||
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||||
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
|
||||
assert.equal(store.getTask(task.id), undefined);
|
||||
assert.equal(await settled, "completed");
|
||||
assert.equal(starts, 0, "a newer completed attempt must replace the captured old failure");
|
||||
});
|
||||
|
||||
test("an unchanged owner retains its original transport rejection", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "unchanged-rejected-owner", parentTaskId: undefined };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
|
||||
const error = new Error("original transport failure");
|
||||
await assert.rejects(runTransferAndWaitForOwner(task, async () => { throw error; }, () => false),
|
||||
(actual) => actual === error);
|
||||
});
|
||||
|
||||
for (const activation of ["patch", "progress", "owner"] as const) {
|
||||
test(`initial stale identity exemption ends when another owner activates that row: ${activation}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "activated-initial-identity", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "paused", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
const initial = store.getTask(task.id)!;
|
||||
latchTransferPause(task.id);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, initial);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
resetTransferPauseLatchesForTests();
|
||||
await settled;
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
store.dismiss(task.id);
|
||||
});
|
||||
resetTransferPauseLatchesForTests();
|
||||
if (activation === "progress") {
|
||||
store.ingestBackgroundEvent({ type: "progress", transferId: task.id,
|
||||
transferred: 1, lifecycleEpoch: 2, lifecycleState: "transferring" });
|
||||
} else if (activation === "owner") store.patchTask(task.id, { ownerId: "replacement-owner" });
|
||||
else store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 2 });
|
||||
assert.match(String(await Promise.race([
|
||||
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
])), /identity changed/i);
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test("already active different identity is rejected before first admission", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "already-active-identity", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
|
||||
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
|
||||
let starts = 0;
|
||||
let conflicts = 0;
|
||||
await assert.rejects(runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => false,
|
||||
undefined, undefined, () => { conflicts += 1; }), /identity changed/i);
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(conflicts, 1);
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
|
||||
});
|
||||
|
||||
for (const replacementStatus of ["transferring", "completed"] as const) {
|
||||
for (const oldStatus of ["completed", "failed"] as const) {
|
||||
test(`deferred ${oldStatus} cannot overwrite a ${replacementStatus} replacement after waiter exit`, async () => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
const task = child();
|
||||
const root = { ...task, id: task.parentTaskId!, parentTaskId: undefined, isDirectory: true };
|
||||
const batcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096, hasTask: () => true,
|
||||
upsertTasks: (updates) => store.upsertTasks(updates),
|
||||
});
|
||||
store.upsertTasks([root, task]);
|
||||
let observation: ReturnType<typeof store.observeTaskSettlement> | undefined;
|
||||
try {
|
||||
await runTransferAndWaitForOwner(task, async () => {
|
||||
store.patchTask(task.id, { status: oldStatus });
|
||||
return {};
|
||||
}, () => false, undefined, undefined, () => batcher.discard(task.id), (value) => {
|
||||
observation = value;
|
||||
return true;
|
||||
});
|
||||
assert.ok(observation);
|
||||
batcher.push({ ...task, status: oldStatus }, observation);
|
||||
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: replacementStatus }]);
|
||||
const replacement = store.getTask(task.id);
|
||||
batcher.flush();
|
||||
assert.equal(store.getTask(task.id), replacement);
|
||||
if (replacementStatus === "transferring") assert.equal(replacement?.directoryEntryIdentity, "b".repeat(64));
|
||||
else assert.equal(replacement, undefined, "the replacement remains compacted");
|
||||
assert.equal(observation.read(), undefined, "the retained observation is disposed");
|
||||
} finally {
|
||||
batcher.flush();
|
||||
observation?.dispose();
|
||||
store.patchTask(root.id, { status: "completed" });
|
||||
store.dismiss(root.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export interface SftpConnectOptions {
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** When false, never attach SFTP to a live terminal SSH transport. */
|
||||
reuseTransport?: boolean;
|
||||
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
|
||||
connectRequestKey?: string;
|
||||
/** Identifies the exact tab and connection created for a route-bound request. */
|
||||
@@ -1074,6 +1076,7 @@ export const useSftpConnections = ({
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
...(options?.reuseTransport === false ? { reuseTransport: false } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,60 @@ test("first-open follow uses a fresh terminal cwd instead of the cached home pat
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow stays put when the terminal cwd is still an unresolved ~", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/root",
|
||||
homeDir: "/root",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "~",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async () => "~",
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
assert.equal(connection.currentPath, "/root");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
resolveTerminalCwdForSftp,
|
||||
isSftpFollowTargetPath,
|
||||
fallbackFollowPathForUntrackedSession,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
@@ -23,6 +26,7 @@ import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActio
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -48,6 +52,7 @@ type GetTerminalCwd = (options?: {
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -85,13 +90,21 @@ const runInitialFollowTerminalCwdSync = async ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetPath = resolveTerminalCwdForSftp(cwd, live.homeDir, live.currentPath);
|
||||
if (!isSftpFollowTargetPath(targetPath)) {
|
||||
// Prompt-only shortcuts such as `~` are not a real SFTP path yet. Stay on
|
||||
// the connection's landing directory instead of listing `/~`.
|
||||
setHandled({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== targetPath ? staleTerminalCwd : targetPath,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
if (live.currentPath === targetPath) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
const navigateResult = await navigate(targetPath, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
@@ -174,6 +187,7 @@ export function useSftpFollowTerminalCwd({
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
const lastFollowOriginIdRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
@@ -257,10 +271,16 @@ export function useSftpFollowTerminalCwd({
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
cwd,
|
||||
connection?.homeDir,
|
||||
connection?.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, { shouldApply });
|
||||
if (navigateResult !== "reached" || !shouldApply()) return;
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
@@ -275,9 +295,23 @@ export function useSftpFollowTerminalCwd({
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const previousOriginId = lastFollowOriginIdRef.current;
|
||||
const originChanged = Boolean(
|
||||
previousOriginId
|
||||
&& expectedSessionId
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
if (!terminalCwd && originChanged) {
|
||||
// Do not reuse an in-flight probe from the pane we just left.
|
||||
terminalCwd = fallbackFollowPathForUntrackedSession({
|
||||
originChanged,
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
@@ -335,26 +369,36 @@ export function useSftpFollowTerminalCwd({
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
if (!connection) return;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
terminalCwd,
|
||||
connection.homeDir,
|
||||
connection.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
quiet: true,
|
||||
});
|
||||
if (!shouldApplyCurrentFollowSync()) return;
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) return;
|
||||
if (navigateResult === "failed") {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
@@ -380,6 +424,7 @@ export function useSftpFollowTerminalCwd({
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
@@ -387,6 +432,7 @@ export function useSftpFollowTerminalCwd({
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
@@ -533,7 +579,7 @@ export function useSftpFollowTerminalCwd({
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply, quiet: true });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSftpHostCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
|
||||
const identities = [
|
||||
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
|
||||
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
|
||||
] as Identity[];
|
||||
const savedHost = host({ identityId: "root-id" });
|
||||
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
|
||||
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
|
||||
assert.equal(credentials.username, "deploy");
|
||||
assert.equal(credentials.password, "good-deploy");
|
||||
assert.equal(credentials.hostId, savedHost.id);
|
||||
assert.equal(savedHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { SftpPane } from "./types";
|
||||
import {
|
||||
getFileName,
|
||||
@@ -55,6 +56,8 @@ export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
/** Restore the previous listing without showing a pane error. */
|
||||
quiet?: boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
@@ -416,8 +419,9 @@ export const useSftpPaneActions = ({
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: options?.quiet
|
||||
? previousError
|
||||
: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
@@ -1030,6 +1034,30 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const removeListedNames = useCallback((
|
||||
side: "left" | "right",
|
||||
parentPath: string,
|
||||
names: string[],
|
||||
) => {
|
||||
if (names.length === 0) return;
|
||||
const removeSet = new Set(names);
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.currentPath !== parentPath) return prev;
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of names) nextSelection.delete(name);
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
}, [clearCacheForConnection, getActivePane, updateActiveTab]);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
@@ -1047,6 +1075,7 @@ export const useSftpPaneActions = ({
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
import {
|
||||
bumpTransferControlEpoch,
|
||||
settleTransferControlEpochTree,
|
||||
} from "./transferControlEpoch";
|
||||
import { bumpTransferControlEpoch } from "./transferControlEpoch";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import {
|
||||
clearTransferCancelled,
|
||||
@@ -48,7 +45,6 @@ import {
|
||||
isTransferCancelledFlag,
|
||||
markTransferCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./transferCancelLatch";
|
||||
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
|
||||
import type { TransferConnectionLease } from "./transferConnectionPool";
|
||||
@@ -446,7 +442,7 @@ export const useSftpTransfers = ({
|
||||
cleanupTaskArtifacts,
|
||||
});
|
||||
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry } = useSftpTransferConflictOps();
|
||||
|
||||
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
ownerId,
|
||||
@@ -844,9 +840,14 @@ export const useSftpTransfers = ({
|
||||
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
// Merge/Replace onto the source directory is already satisfied. Walking
|
||||
// it would rewrite its children (including replacing links with files).
|
||||
// Duplicate has a different target and continues through normal copying.
|
||||
let directoryHandled = task.isDirectory
|
||||
&& await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding);
|
||||
if (
|
||||
task.isDirectory
|
||||
&& !directoryHandled
|
||||
&& !task.replaceExistingTarget
|
||||
&& task.resumable === false
|
||||
&& sameHost
|
||||
@@ -866,10 +867,10 @@ export const useSftpTransfers = ({
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
dirHandledBySameHost = result.success;
|
||||
directoryHandled = result.success;
|
||||
}
|
||||
|
||||
if (task.isDirectory && !dirHandledBySameHost) {
|
||||
if (task.isDirectory && !directoryHandled) {
|
||||
// For directory transfers, parent task uses:
|
||||
// totalBytes = total file count (discovered async)
|
||||
// transferredBytes = completed file count (incremented by child completions)
|
||||
@@ -1809,127 +1810,109 @@ export const useSftpTransfers = ({
|
||||
return "attention";
|
||||
}
|
||||
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
const patchDownload = (updates: Partial<TransferTask>) => {
|
||||
transferRuntime.patchTask(task.id, updates);
|
||||
const canonical = transferRuntime.getTask(task.id);
|
||||
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
|
||||
};
|
||||
const executeDownload = async (): Promise<TransferStatus> => {
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
await waitUntilTransferResumed(task.id);
|
||||
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
|
||||
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
|
||||
|
||||
try {
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Use childFailureCount (tracked outside React state) to determine
|
||||
// final status reliably, regardless of render timing.
|
||||
// Cancel must win: transferDirectory counts cancelled children as errors,
|
||||
// but cancelTransfer already marked the parent cancelled — do not demote
|
||||
// it to failed with "Some files failed to transfer".
|
||||
// Re-read cancel inside the state update so a cancel that lands after
|
||||
// transferDirectory returns cannot be overwritten by completed/failed.
|
||||
let appliedStatus: TransferStatus = "completed";
|
||||
setTransfers((prev) => {
|
||||
const liveParent = prev.find((candidate) => candidate.id === task.id);
|
||||
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
|
||||
(t) => t.parentTaskId === task.id && t.status === "completed",
|
||||
).length;
|
||||
return prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
const parentCancelled = t.status === "cancelled"
|
||||
|| cancelledTasksRef.current.has(task.id);
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled,
|
||||
childFailureCount,
|
||||
});
|
||||
appliedStatus = resolved.status;
|
||||
if (resolved.status === "cancelled") {
|
||||
cancelledTasksRef.current.delete(task.id);
|
||||
return {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
// Keep partial progress — do not look 100% complete when cancelled.
|
||||
speed: 0,
|
||||
};
|
||||
}
|
||||
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
|
||||
const hasFailures = resolved.status === "failed";
|
||||
return {
|
||||
...t,
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: hasFailures ? completedCount : finalTotal,
|
||||
speed: 0,
|
||||
};
|
||||
// Runtime owns lifecycle while the walk is registered. Publish the
|
||||
// final root there before mirroring it to the panel.
|
||||
const rows = sftpTransferCenterStore.getSnapshot().tasks;
|
||||
const liveParent = transferRuntime.getTask(task.id) ?? task;
|
||||
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
|
||||
childFailureCount,
|
||||
});
|
||||
});
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
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 = err instanceof Error ? err.message : String(err);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? {
|
||||
...t,
|
||||
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
const childIds = sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((candidate) => candidate.parentTaskId === task.id)
|
||||
.map((candidate) => candidate.id);
|
||||
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
|
||||
settleTransferControlEpochTree(task.id, relatedChildIds);
|
||||
}
|
||||
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
|
||||
patchDownload({
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
...(resolved.status === "cancelled" ? {} : {
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
|
||||
}),
|
||||
});
|
||||
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
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 = err instanceof Error ? err.message : String(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
}
|
||||
};
|
||||
let result: TransferStatus = "failed";
|
||||
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
|
||||
transferRuntime.runWalk(task.id, async () => {
|
||||
result = await executeDownload();
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sftpSessionsRef, acquireTransferSession],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
/** The displaced invocation may fail its walk, but no longer owns the child row. */
|
||||
export class TransferOwnerChangedError extends Error {}
|
||||
|
||||
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
@@ -9,33 +14,47 @@ export async function runTransferAndWaitForOwner(
|
||||
start: () => Promise<StreamResult>,
|
||||
shouldAbort: () => boolean,
|
||||
pausedAtResume?: TransferTask,
|
||||
completedAtRestart?: TransferTask,
|
||||
onOwnerChanged?: () => void,
|
||||
retainObservation?: (observation: TransferOwnerObservation) => boolean,
|
||||
existingObservation?: TransferOwnerObservation,
|
||||
): Promise<StreamResult> {
|
||||
// Register before admission/start: an owner may finish while dispatch waits for resume.
|
||||
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
let observation = existingObservation ?? sftpTransferCenterStore.observeTaskSettlement(task, completedAtRestart, onOwnerChanged);
|
||||
try {
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume, completedAtRestart);
|
||||
if (admission === "cancelled") throw new Error("Transfer cancelled");
|
||||
if (admission === "completed" || observation.read()?.status === "completed") return {};
|
||||
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
|
||||
if (admission === "conflict") throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
|
||||
if (admission === "ready") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
// Admission begins a new attempt; discard any previous failed settlement.
|
||||
observation.dispose();
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
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) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
const latest = observation.read();
|
||||
if (latest?.status === "completed") return { ...result, superseded: false };
|
||||
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
|
||||
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} catch (error) {
|
||||
// A displaced invocation can reject instead of returning superseded. Its
|
||||
// transport error must not regain permission to write the winning row.
|
||||
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
|
||||
throw error;
|
||||
} finally {
|
||||
observation.dispose();
|
||||
// A deferred child update may outlive the invocation. Its caller must either
|
||||
// retain this evidence through the write or release it after handling exit.
|
||||
if (!retainObservation?.(observation)) observation.dispose();
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,14 @@ import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryR
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
expected: TransferTask;
|
||||
initialInactiveRow?: TransferTask;
|
||||
settled?: TransferTask;
|
||||
conflicted?: boolean;
|
||||
ignoredCompletion?: TransferTask;
|
||||
onIdentityConflict?: () => void;
|
||||
};
|
||||
|
||||
// Ordinary bounded history restores synchronously so existing callers receive
|
||||
// it immediately. Legacy directory snapshots can contain tens of thousands of
|
||||
@@ -98,9 +106,9 @@ export interface SftpTransferCenterStore {
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
getTask(taskId: string): TransferTask | undefined;
|
||||
/** Observe exact task settlement before completed child rows are compacted. */
|
||||
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
|
||||
observeTaskSettlement(task: TransferTask, ignoredCompletion?: TransferTask, onIdentityConflict?: () => void): { read(): TransferTask | undefined; hasIdentityConflict(): boolean; dispose(): void };
|
||||
/** Publish an explicit dispatch identity without flushing large child-history batches. */
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask, completedAtRestart?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
getOwnerTasks(ownerId: string): TransferTask[];
|
||||
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
|
||||
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
|
||||
@@ -109,6 +117,8 @@ export interface SftpTransferCenterStore {
|
||||
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
|
||||
upsertTasks(incoming: readonly TransferTask[]): void;
|
||||
canControl(taskId: string): boolean;
|
||||
subscribeResume(listener: Listener): () => void;
|
||||
isResuming(taskId: string): boolean;
|
||||
pause(taskId: string): Promise<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
cancel(taskId: string): Promise<void>;
|
||||
@@ -403,7 +413,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
const progressListeners = new Set<Listener>();
|
||||
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
|
||||
const settlementObservers = new Map<string, Set<TaskSettlementObserver>>();
|
||||
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
|
||||
expected.id === candidate.id
|
||||
&& expected.sourcePath === candidate.sourcePath
|
||||
@@ -412,6 +422,29 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
|
||||
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
|
||||
);
|
||||
const captureObservedTask = (task: TransferTask) => {
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (observer.conflicted || task === observer.ignoredCompletion) continue;
|
||||
const matchesExpected = matchesObservedTask(observer.expected, task);
|
||||
// A retry may plan a new source identity while its initial stale row is
|
||||
// still paused. Only the unchanged initial attempt is exempt: activating
|
||||
// it or replacing its owner/epoch must not let the stale plan reclaim it.
|
||||
if (!matchesExpected && observer.initialInactiveRow
|
||||
&& matchesObservedTask(observer.initialInactiveRow, task)
|
||||
&& observer.initialInactiveRow.status === task.status
|
||||
&& observer.initialInactiveRow.lifecycleEpoch === task.lifecycleEpoch
|
||||
&& observer.initialInactiveRow.ownerId === task.ownerId) continue;
|
||||
observer.initialInactiveRow = undefined;
|
||||
if (!matchesExpected) {
|
||||
observer.conflicted = true;
|
||||
observer.onIdentityConflict?.();
|
||||
} else if (observer.settled?.status !== "completed") {
|
||||
// Failed/cancelled evidence belongs to the latest observed attempt. A
|
||||
// resumed owner can supersede it; exact successful completion stays final.
|
||||
observer.settled = TERMINAL_OWNER_STATUSES.has(task.status) ? task : undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
const refreshBadgeSnapshot = () => {
|
||||
const next = buildBadgeSnapshot(tasks);
|
||||
if (
|
||||
@@ -431,6 +464,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let persistenceDirty = false;
|
||||
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const resumeInvocations = new Map<string, Promise<void>>();
|
||||
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
|
||||
const resumeRequests = new Map<string, symbol>();
|
||||
const resumeListeners = new Set<Listener>();
|
||||
const notifyResume = () => resumeListeners.forEach((listener) => listener());
|
||||
const clearResumeRequest = (taskId: string) => {
|
||||
if (resumeRequests.delete(taskId)) notifyResume();
|
||||
};
|
||||
const resumePreparationFailures = new Map<string, string>();
|
||||
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
|
||||
const dedicatedResumeWaiters = new Set<{
|
||||
@@ -624,12 +664,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// settles; everything else can release its whole tree here.
|
||||
settleFinishedTransferControlState(tasks, tasks);
|
||||
if (settlementObservers.size > 0) {
|
||||
for (const task of tasks) {
|
||||
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
|
||||
}
|
||||
}
|
||||
for (const task of tasks) captureObservedTask(task);
|
||||
}
|
||||
const beforePrune = tasks;
|
||||
tasks = pruneSftpTransferHistory(tasks);
|
||||
@@ -1329,7 +1364,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume) {
|
||||
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
|
||||
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
|
||||
@@ -1339,7 +1374,10 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|
||||
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
|
||||
if (existing?.status === "cancelled") return "cancelled";
|
||||
if (existing?.status === "completed") {
|
||||
// Only the exact completion captured at the destination reset is
|
||||
// stale. A newer completion or user control must keep its authority.
|
||||
const restartsCompletion = existing?.status === "completed" && existing === completedAtRestart;
|
||||
if (existing?.status === "completed" && !restartsCompletion) {
|
||||
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
|
||||
}
|
||||
const resumesUnchangedPause = existing === pausedAtResume
|
||||
@@ -1360,6 +1398,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
...existing,
|
||||
directoryEntryIndex: incoming.directoryEntryIndex,
|
||||
directoryEntryIdentity: incoming.directoryEntryIdentity,
|
||||
...(restartsCompletion ? {
|
||||
checkpointBytes: incoming.checkpointBytes,
|
||||
transferredBytes: incoming.transferredBytes,
|
||||
resumeStage: incoming.resumeStage,
|
||||
downloadCheckpointBytes: incoming.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: incoming.uploadCheckpointBytes,
|
||||
sourceFingerprint: incoming.sourceFingerprint,
|
||||
} : {}),
|
||||
...(existing.status === "transferring" ? {} : {
|
||||
status: "transferring" as const,
|
||||
lifecycleEpoch: undefined,
|
||||
@@ -1369,25 +1415,36 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
}),
|
||||
};
|
||||
tasks = next;
|
||||
captureObservedTask(next[index]);
|
||||
emitProgress(false);
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected) {
|
||||
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
|
||||
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
|
||||
const initial = tasks.find((task) => task.id === expected.id);
|
||||
const observer: TaskSettlementObserver = {
|
||||
expected: { ...expected }, ignoredCompletion, onIdentityConflict,
|
||||
initialInactiveRow: initial && ["paused", "failed", "interrupted", "attention"].includes(initial.status)
|
||||
&& !matchesObservedTask(expected, initial) ? { ...initial } : undefined,
|
||||
};
|
||||
const observers = settlementObservers.get(expected.id) ?? new Set();
|
||||
observers.add(observer);
|
||||
settlementObservers.set(expected.id, observers);
|
||||
// A different active owner may predate registration. Detect it now,
|
||||
// before admission can publish the stale caller's identity over it.
|
||||
if (initial) captureObservedTask(initial);
|
||||
let disposed = false;
|
||||
return {
|
||||
read() {
|
||||
if (disposed) return undefined;
|
||||
if (observer.settled) return observer.settled;
|
||||
const current = tasks.find((task) => task.id === expected.id);
|
||||
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
return current && current !== ignoredCompletion && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
},
|
||||
hasIdentityConflict: () => !disposed && observer.conflicted === true,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
observer.settled = undefined;
|
||||
observer.conflicted = undefined;
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) settlementObservers.delete(expected.id);
|
||||
},
|
||||
@@ -1653,6 +1710,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const next = tasks.slice();
|
||||
next[index] = nextTask;
|
||||
tasks = next;
|
||||
captureObservedTask(nextTask);
|
||||
if (changed) {
|
||||
const lifecycleChanged = (
|
||||
(updates.status !== undefined && updates.status !== task.status)
|
||||
@@ -1725,114 +1783,140 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
controls.adopt && controls.canAdopt?.(task)
|
||||
)));
|
||||
},
|
||||
pause: (taskId) => invoke(taskId, "pause"),
|
||||
subscribeResume(listener) {
|
||||
resumeListeners.add(listener);
|
||||
return () => { resumeListeners.delete(listener); };
|
||||
},
|
||||
isResuming(taskId) {
|
||||
if (!resumeRequests.has(taskId)) return false;
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
return !!task && !task.error
|
||||
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
|
||||
},
|
||||
pause(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "pause");
|
||||
},
|
||||
async resume(taskId) {
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
const request = Symbol("resume");
|
||||
resumeRequests.set(taskId, request);
|
||||
notifyResume();
|
||||
const runResume = async () => {
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
return existing;
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
return startFresh();
|
||||
};
|
||||
try {
|
||||
return await runResume();
|
||||
} finally {
|
||||
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
|
||||
}
|
||||
return startFresh();
|
||||
},
|
||||
cancel: (taskId) => invoke(taskId, "cancel"),
|
||||
cancel(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "cancel");
|
||||
},
|
||||
async retry(taskId) {
|
||||
const ownerId = findOwner(taskId);
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
@@ -1988,7 +2072,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
backgroundEventsDuringRestore.set(event.transferId, pending);
|
||||
return;
|
||||
}
|
||||
let existing = tasks.find((task) => task.id === event.transferId);
|
||||
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
|
||||
let existing = tasks[existingIndex];
|
||||
const eventParent = event.parentTaskId
|
||||
? tasks.find((task) => task.id === event.parentTaskId)
|
||||
: undefined;
|
||||
@@ -2004,7 +2089,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
directoryEntryIdentity: event.directoryEntryIdentity ?? task.directoryEntryIdentity,
|
||||
background: false,
|
||||
} : task);
|
||||
existing = tasks.find((task) => task.id === event.transferId);
|
||||
existing = tasks[existingIndex];
|
||||
captureObservedTask(existing);
|
||||
}
|
||||
// A delayed child progress event can arrive after the renderer compacted
|
||||
// that completed child into its parent checkpoint. Never resurrect it as
|
||||
@@ -2084,6 +2170,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if ((event.type === "queued" || event.type === "started" || event.type === "progress") && !existing) {
|
||||
const sourcePath = event.sourcePath ?? "";
|
||||
const targetPath = event.targetPath ?? "";
|
||||
existingIndex = tasks.length;
|
||||
tasks.push({
|
||||
id: event.transferId,
|
||||
ownerId: eventParent?.ownerId
|
||||
@@ -2317,6 +2404,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
phase: undefined,
|
||||
} : task);
|
||||
}
|
||||
// Ownership changes must be observed synchronously even when a progress
|
||||
// event takes the lightweight UI path. Reuse the located row, not a scan.
|
||||
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
|
||||
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
|
||||
// events still take the full emit path so UI never lags on pause/cancel.
|
||||
if (event.type === "progress" && !persistImmediately) {
|
||||
@@ -2430,3 +2520,16 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
|
||||
() => fallbackRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared control feedback for every transfer surface, including newly mounted rows. */
|
||||
export function useSftpTransferResuming(taskId: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
|
||||
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
|
||||
return () => { unsubscribeTask(); unsubscribeResume(); };
|
||||
},
|
||||
() => sftpTransferCenterStore.isResuming(taskId),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
|
||||
|
||||
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
|
||||
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, "window", previous);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
let settle!: (value: { success: boolean; reason?: string }) => void;
|
||||
let started!: () => void;
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
resumeTransfer: () => { started(); return gate; },
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
|
||||
cancelTransfer: async () => ({ success: true }),
|
||||
} } });
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("feedback", [{
|
||||
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
|
||||
speed: 0, startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
|
||||
t.after(unsubscribe);
|
||||
const running = store.resume("feedback");
|
||||
assert.equal(store.isResuming("feedback"), true);
|
||||
assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
|
||||
await entered;
|
||||
const followup = outcome === "pause" ? store.pause("feedback")
|
||||
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
|
||||
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
|
||||
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
|
||||
await Promise.all([running, followup]);
|
||||
assert.equal(store.isResuming("feedback"), false);
|
||||
assert.ok(notifications >= 2);
|
||||
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
|
||||
assert.ok(hook);
|
||||
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
|
||||
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
|
||||
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
|
||||
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
|
||||
let visible = true;
|
||||
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
|
||||
sidePanelLiveStore.update(own);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a');
|
||||
visible = false;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => { sidePanelLiveStore.update(other); });
|
||||
visible = true;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
|
||||
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
|
||||
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
|
||||
let panelTabId = 'workspace-a';
|
||||
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
|
||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
await act(async () => { sidePanelLiveStore.update(own); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-a');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
panelTabId = 'new-owner';
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
type Listener = () => void;
|
||||
|
||||
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
|
||||
/**
|
||||
* Live terminal CWD map + version token.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
|
||||
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
|
||||
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
import {
|
||||
formatVaultNoteReferences,
|
||||
isVaultNoteAttachment,
|
||||
} from "./vaultNoteAttachment";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments whose text is inlined into the prompt instead of being sent as
|
||||
* file parts: terminal selections and Vault note mentions.
|
||||
*/
|
||||
export function isInlineTextAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
|
||||
): boolean {
|
||||
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
|
||||
|
||||
const blocks = [...terminalBlocks, ...noteBlocks];
|
||||
if (blocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return blocks.join("").trimStart();
|
||||
return `${prompt}${blocks.join("")}`;
|
||||
}
|
||||
@@ -2,10 +2,15 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampTerminalSidePanelHeight,
|
||||
clampTerminalSidePanelWidth,
|
||||
getTerminalSidePanelAvailableWidth,
|
||||
getTerminalSidePanelMaxShownTools,
|
||||
getTerminalSidePanelMaxWidth,
|
||||
getTerminalSidePanelAvailableHeight,
|
||||
getTerminalSidePanelMaxHeight,
|
||||
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MAX_WIDTH,
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
} from './terminalSidePanelWidth.ts';
|
||||
@@ -34,3 +39,17 @@ test('terminal side panel keeps usable terminal space in smaller windows', () =>
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
});
|
||||
|
||||
test('terminal side panel bottom dock clamps height against surface height', () => {
|
||||
assert.equal(getTerminalSidePanelAvailableHeight(1000, 200), 800);
|
||||
assert.equal(getTerminalSidePanelMaxHeight(1000), 760);
|
||||
assert.equal(clampTerminalSidePanelHeight(900, 1000), 760);
|
||||
assert.equal(clampTerminalSidePanelHeight(100, 1000), TERMINAL_SIDE_PANEL_MIN_HEIGHT);
|
||||
assert.equal(clampTerminalSidePanelHeight(300, 1000, 500), 500);
|
||||
assert.equal(clampTerminalSidePanelHeight(5000, 4000), TERMINAL_SIDE_PANEL_MAX_HEIGHT);
|
||||
// A tall compose bar in the smallest window leaves less than the nominal
|
||||
// panel minimum; the terminal's 240px reserve must still win.
|
||||
assert.equal(getTerminalSidePanelMaxHeight(244), 4);
|
||||
assert.equal(clampTerminalSidePanelHeight(560, 244), 4);
|
||||
assert.equal(clampTerminalSidePanelHeight(560, 220), 0);
|
||||
});
|
||||
@@ -41,3 +41,37 @@ export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_MIN_HEIGHT = 200;
|
||||
export const TERMINAL_SIDE_PANEL_MAX_HEIGHT = 1200;
|
||||
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT = 240;
|
||||
// Shared tab toolbar rendered above the pane tree (`h-9`).
|
||||
export const TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT = 36;
|
||||
|
||||
export function getTerminalSidePanelAvailableHeight(
|
||||
terminalLayerHeight: number,
|
||||
occupiedSiblingHeight: number,
|
||||
): number {
|
||||
return Math.max(0, terminalLayerHeight - occupiedSiblingHeight);
|
||||
}
|
||||
|
||||
export function getTerminalSidePanelMaxHeight(availableSurfaceHeight: number): number {
|
||||
const availableHeight = availableSurfaceHeight - TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(TERMINAL_SIDE_PANEL_MAX_HEIGHT, availableHeight),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampTerminalSidePanelHeight(
|
||||
height: number,
|
||||
availableSurfaceHeight: number,
|
||||
contentMinimumHeight = TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
): number {
|
||||
const maximumHeight = getTerminalSidePanelMaxHeight(availableSurfaceHeight);
|
||||
const minimumHeight = Math.min(
|
||||
maximumHeight,
|
||||
Math.max(TERMINAL_SIDE_PANEL_MIN_HEIGHT, contentMinimumHeight),
|
||||
);
|
||||
return Math.max(minimumHeight, Math.min(maximumHeight, height));
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type TerminalSessionInfo,
|
||||
} from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { useAgentCompactionUi } from './useAgentCompactionUi';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
export type { ActiveCompactionUi } from './useAgentCompactionUi';
|
||||
@@ -134,6 +135,7 @@ export function useAIChatStreaming({
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
const { t } = useI18n();
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
|
||||
() => new Set(sharedStreamingSessionIds),
|
||||
);
|
||||
@@ -195,7 +197,8 @@ export function useAIChatStreaming({
|
||||
setStreamingForScope,
|
||||
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
|
||||
persistContextCompaction,
|
||||
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
|
||||
translate: t,
|
||||
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction, t]);
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
|
||||
@@ -174,10 +174,13 @@ export function useAgentDiscovery(
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
||||
: match.command === 'opencode'
|
||||
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
|
||||
: ea.env;
|
||||
: match.command === 'mimo'
|
||||
? { ...(ea.env ?? {}), MIMOCODE_BIN: matchPath }
|
||||
: ea.env;
|
||||
const envChanged =
|
||||
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath)
|
||||
|| (match.command === 'mimo' && ea.env?.MIMOCODE_BIN !== matchPath);
|
||||
const versionChanged = Boolean(match.version) && ea.cliVersion !== match.version;
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged || versionChanged) {
|
||||
changed = true;
|
||||
@@ -220,7 +223,9 @@ export function useAgentDiscovery(
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: agent.command === 'opencode'
|
||||
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
: agent.command === 'mimo'
|
||||
? { env: { MIMOCODE_BIN: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
Loaded 100 of 730 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user