mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cbcb73b61 | ||
|
|
d3eb3007bb | ||
|
|
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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -9,8 +9,11 @@ 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;
|
||||
}
|
||||
@@ -677,48 +680,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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -498,8 +502,8 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
prepareApply: () =>
|
||||
prepareSyncPayloadApply(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -521,14 +525,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 }),
|
||||
@@ -1009,27 +1016,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],
|
||||
);
|
||||
|
||||
@@ -1751,6 +1749,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 +1892,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
runSnippet: handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
toggleScriptsSidePanelRef,
|
||||
@@ -1962,6 +1969,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
handleEndSessionDrag,
|
||||
|
||||
@@ -504,6 +504,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -28,3 +28,35 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,23 +70,31 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -97,19 +105,21 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
|
||||
'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 +54,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 +235,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',
|
||||
@@ -471,6 +485,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 +512,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',
|
||||
|
||||
@@ -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.',
|
||||
@@ -358,6 +359,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
@@ -649,6 +653,8 @@ Highlight the focused split pane:
|
||||
'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).',
|
||||
@@ -706,6 +712,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.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
@@ -251,6 +253,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 +640,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 +811,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',
|
||||
|
||||
@@ -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',
|
||||
@@ -410,6 +417,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
@@ -710,8 +718,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',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const esAiMessages: Messages = {
|
||||
'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 +226,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',
|
||||
@@ -471,6 +476,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 +503,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',
|
||||
|
||||
@@ -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.',
|
||||
@@ -273,6 +274,9 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
@@ -564,6 +568,8 @@ Resalta el panel dividido enfocado:
|
||||
'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ú).',
|
||||
@@ -621,6 +627,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.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.',
|
||||
'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',
|
||||
@@ -251,6 +253,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 +640,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 +811,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',
|
||||
|
||||
@@ -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,7 @@ export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
@@ -206,6 +207,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': 'Выберите вариант',
|
||||
@@ -423,6 +444,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 +471,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': 'Команды /',
|
||||
|
||||
@@ -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': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
@@ -342,6 +343,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
@@ -606,6 +610,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+правый щелчок всё равно открывает меню).',
|
||||
@@ -663,6 +669,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 и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
@@ -269,6 +271,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 +655,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToLeft': 'Закрыть вкладки слева',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
@@ -814,6 +818,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': 'Кодировка',
|
||||
|
||||
@@ -154,6 +154,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 +304,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 +754,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',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'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 +54,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 +235,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 需要你的输入',
|
||||
@@ -471,6 +485,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 +512,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': '快捷命令',
|
||||
|
||||
@@ -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': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
@@ -355,6 +357,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
@@ -781,6 +786,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} 导入...',
|
||||
@@ -1040,6 +1050,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.refreshAfterCommand': '终端命令完成后刷新',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
@@ -289,6 +291,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+右键仍可打开菜单)。',
|
||||
@@ -343,6 +347,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 +731,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToLeft': '关闭左侧标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +902,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': '字符编码',
|
||||
|
||||
@@ -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',
|
||||
@@ -639,6 +644,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 +980,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': '本地',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'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 +226,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 需要你的輸入',
|
||||
@@ -470,6 +475,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 +502,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': '快捷指令',
|
||||
|
||||
@@ -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': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
|
||||
@@ -358,6 +359,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
@@ -784,6 +788,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} 匯入...',
|
||||
@@ -1043,6 +1052,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
@@ -289,6 +291,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+右鍵仍可開啟選單)。',
|
||||
@@ -343,6 +347,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 +731,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
'tabs.closeToLeft': '關閉左側分頁',
|
||||
'tabs.closeToRight': '關閉右側分頁',
|
||||
'tabs.closeAll': '關閉所有分頁',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +902,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': '字元編碼',
|
||||
|
||||
@@ -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',
|
||||
@@ -639,6 +644,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 +980,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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
@@ -55,6 +56,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
@@ -80,6 +82,7 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -100,6 +102,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -300,6 +304,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -90,6 +92,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
@@ -139,6 +142,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -176,7 +180,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, 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 +189,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, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -200,7 +204,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -210,7 +214,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -431,6 +435,9 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
@@ -575,7 +582,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -558,11 +558,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 +620,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 +849,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 +935,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 +951,7 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: t.transferredBytes + 1,
|
||||
transferredBytes: completed,
|
||||
speed: t.speed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let count = 0;
|
||||
let options = {
|
||||
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
|
||||
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
|
||||
path: '/browsed', connected: true, refresh: async () => { count += 1; },
|
||||
};
|
||||
function Probe() { useSftpCommandRefresh(options); return null; }
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const update = async (patch: Partial<typeof options>) => {
|
||||
options = { ...options, ...patch };
|
||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
||||
};
|
||||
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
|
||||
publishTerminalCommandCompletion('another-terminal');
|
||||
await finish();
|
||||
assert.equal(count, 0);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
|
||||
await update(patch);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
|
||||
}
|
||||
await update({ busy: true });
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ busy: false });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await update(patch);
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
}
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
|
||||
interface Options {
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
visible: boolean;
|
||||
busy: boolean;
|
||||
matchesTerminal: boolean;
|
||||
paneId: string;
|
||||
connectionId: string | null;
|
||||
path: string | null;
|
||||
connected: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
|
||||
export function useSftpCommandRefresh(options: Options): void {
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const drain = useRef(() => {});
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !options.visible || !options.sessionId) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
let refreshing = false;
|
||||
let disposed = false;
|
||||
const eligible = () => {
|
||||
const live = latest.current;
|
||||
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
|
||||
&& live.sessionId === options.sessionId && live.paneId === options.paneId
|
||||
&& live.connectionId === options.connectionId && live.path === options.path;
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!pending || refreshing || latest.current.busy || !eligible()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (!eligible() || latest.current.busy) return;
|
||||
pending = false;
|
||||
refreshing = true;
|
||||
void latest.current.refresh().catch(() => {}).finally(() => {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
drain.current = schedule;
|
||||
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
|
||||
if (sessionId !== options.sessionId || !eligible()) return;
|
||||
pending = true;
|
||||
schedule();
|
||||
});
|
||||
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
|
||||
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
|
||||
useEffect(() => { drain.current(); }, [options.busy]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useSftpPaneActions } from './useSftpPaneActions';
|
||||
import { createEmptyPane, type SftpPane } from './types';
|
||||
import type { SftpFileEntry } from '../../../types';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
|
||||
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
|
||||
let result = [file('kept'), file('new')];
|
||||
const selection = new Set(['kept']);
|
||||
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
|
||||
const left = { current: { tabs: [pane], activeTabId: pane.id } };
|
||||
const calls: string[] = [];
|
||||
let actions: ReturnType<typeof useSftpPaneActions>;
|
||||
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
|
||||
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
|
||||
};
|
||||
function Probe() {
|
||||
actions = useSftpPaneActions({
|
||||
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
|
||||
updateActiveTab: (side, updater) => update(side, pane.id, updater),
|
||||
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
|
||||
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
|
||||
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
|
||||
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
|
||||
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
|
||||
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
|
||||
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
|
||||
assert.deepEqual(calls, ['/browsed']);
|
||||
assert.equal(left.current.tabs[0].selectedFiles, selection);
|
||||
assert.equal(left.current.tabs[0].filter, 'ke');
|
||||
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
|
||||
result = [file('new')];
|
||||
await actions!.refresh('left', { preserveSelection: true });
|
||||
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
|
||||
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
/** Retain selection identity when no selected entry disappeared. */
|
||||
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
|
||||
const names = new Set(files.map((file) => file.name));
|
||||
const retained = new Set([...selected].filter((name) => names.has(name)));
|
||||
return retained.size === selected.size ? selected : retained;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -53,13 +60,14 @@ interface UseSftpPaneActionsParams {
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
preserveSelection?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -179,6 +187,7 @@ export const useSftpPaneActions = ({
|
||||
pane.connection.homeDir,
|
||||
);
|
||||
|
||||
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
|
||||
@@ -281,7 +290,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: normalizedPath }
|
||||
: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -369,7 +378,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path: normalizedPath,
|
||||
files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
@@ -380,7 +389,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: EMPTY_SET,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
@@ -445,7 +454,7 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
@@ -476,7 +485,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -109,6 +109,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>;
|
||||
@@ -431,6 +433,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<{
|
||||
@@ -1725,114 +1734,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);
|
||||
@@ -2430,3 +2465,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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
type Listener = (sessionId: string) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
/** Renderer-local fan-out of the terminal's existing completion detection. */
|
||||
export function publishTerminalCommandCompletion(sessionId: string): void {
|
||||
for (const listener of listeners) listener(sessionId);
|
||||
}
|
||||
|
||||
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -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("")}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
|
||||
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
|
||||
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
t.after(() => {
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
let sharedPassword: string | undefined;
|
||||
let clears = 0;
|
||||
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
|
||||
cloudSyncGetSessionPassword: async () => sharedPassword,
|
||||
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
|
||||
} });
|
||||
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
|
||||
const manager = getCloudSyncManager();
|
||||
await manager.setupMasterKey('old-fixture-password');
|
||||
manager.lock();
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
const { useCloudSync } = await import('./useCloudSync');
|
||||
let current!: ReturnType<typeof useCloudSync>;
|
||||
const Probe = () => { current = useCloudSync(); return null; };
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const originalUnlock = EncryptionService.unlockMasterKey;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
try {
|
||||
await renderer.render(<Probe />);
|
||||
await flushEffects();
|
||||
EncryptionService.unlockMasterKey = async (...args) => {
|
||||
started();
|
||||
await gate;
|
||||
return originalUnlock(...args);
|
||||
};
|
||||
sharedPassword = 'old-fixture-password';
|
||||
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
|
||||
await entered;
|
||||
const state = Reflect.get(manager, 'state') as SyncManagerState;
|
||||
state.masterKeyConfig = newConfig;
|
||||
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
|
||||
sharedPassword = 'new-fixture-password';
|
||||
release();
|
||||
const error = await result;
|
||||
assert.equal(clears, 0, 'stale work must not erase the new shared password');
|
||||
assert.equal(sharedPassword, 'new-fixture-password');
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /changed while unlocking/i);
|
||||
} finally {
|
||||
release();
|
||||
EncryptionService.unlockMasterKey = originalUnlock;
|
||||
await renderer.unmount();
|
||||
manager.destroy();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (!password) return;
|
||||
|
||||
const ok = await manager.unlock(password);
|
||||
if (!ok) {
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
function tree(owner: boolean) {
|
||||
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
|
||||
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
|
||||
React.createElement(Consumer, { key: 'peer' }));
|
||||
}
|
||||
try {
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.ok(pending.length > 0);
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-only']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let cleared = 0;
|
||||
let currentIdentity = 'old-key';
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: Array<[string, string]> = [];
|
||||
const manager = {
|
||||
unlock: async (value: string) => {
|
||||
unlocked.push([currentIdentity, value]);
|
||||
return currentIdentity === 'new-key';
|
||||
},
|
||||
};
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
// The password notification overtakes the config event: the peer is
|
||||
// still locked on the old config when the new password arrives.
|
||||
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
|
||||
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
|
||||
// The config event arrives afterwards; the retained password must
|
||||
// still be available to auto-unlock the rotated key.
|
||||
currentIdentity = 'new-key';
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [
|
||||
['old-key', 'rotation-password'],
|
||||
['new-key', 'rotation-password'],
|
||||
]);
|
||||
assert.equal(cleared, 0);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed auto-unlock does not undo a later deliberate lock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let calls = 0;
|
||||
const manager = { unlock: async () => { calls++; return true; } };
|
||||
let onAvailable: (() => void) | undefined;
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => 'fixture-only',
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
|
||||
};
|
||||
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
|
||||
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1);
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let reads = 0;
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
|
||||
try {
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.ok(reads > 0);
|
||||
assert.deepEqual(unlocked, []);
|
||||
await renderer.render(null);
|
||||
await flushEffects();
|
||||
password = 'fixture-shared-while-absent';
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('remount during an active derivation does not start a duplicate unlock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let finish!: (value: boolean) => void;
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await runWithAct(async () => finish(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(React.StrictMode, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('password notification and a later peer mount share the same attempt', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return false; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = (late: boolean) => React.createElement(React.Fragment, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
|
||||
late ? React.createElement(Consumer, { key: 'late' }) : null);
|
||||
try {
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 0);
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'new consumers share the notification generation');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AutoUnlockManager {
|
||||
unlock(password: string): Promise<boolean>;
|
||||
}
|
||||
interface AutoUnlockBridge {
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
}
|
||||
|
||||
// A renderer can mount several consumers of the same manager. Remember the
|
||||
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
|
||||
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Dedupe attempts per manager (not per hook instance): every consumer of the
|
||||
// same manager observes the same events, and without sharing they would each
|
||||
// run the expensive PBKDF2 derivation inside manager.unlock.
|
||||
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
|
||||
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
|
||||
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Mounted consumers register a retry trigger here so an attempt can be handed
|
||||
// off when its owner unmounts (see the cleanup below).
|
||||
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
|
||||
|
||||
/** Retry a locked peer window when the setting window finishes sharing its key. */
|
||||
export function useCloudSyncAutoUnlock(input: {
|
||||
securityState: string;
|
||||
masterKeyIdentity: string | null;
|
||||
manager: AutoUnlockManager;
|
||||
bridge: AutoUnlockBridge | null | undefined;
|
||||
}) {
|
||||
const { securityState, masterKeyIdentity, manager, bridge } = input;
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
|
||||
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
|
||||
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
|
||||
setRetryRevision(value => value + 1);
|
||||
}), [bridge, manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterKeyIdentity) return;
|
||||
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
|
||||
if (securityState === 'UNLOCKED') {
|
||||
unlockedIdentityByManager.set(manager, masterKeyIdentity);
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
return;
|
||||
}
|
||||
if (securityState !== 'LOCKED') return;
|
||||
// Once this key has been unlocked, a later lock is intentional. A peer
|
||||
// sharing its password must not undo it, regardless of notification order.
|
||||
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
|
||||
// Register before the dedup check: a consumer that only observes an
|
||||
// in-flight attempt must still be reachable for an ownership handoff.
|
||||
let listeners = retryListenersByManager.get(manager);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
retryListenersByManager.set(manager, listeners);
|
||||
}
|
||||
const notifyRetry = () => setRetryRevision(value => value + 1);
|
||||
listeners.add(notifyRetry);
|
||||
const removeListener = () => {
|
||||
listeners.delete(notifyRetry);
|
||||
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
};
|
||||
if (attemptedAttemptByManager.get(manager) === attempt) {
|
||||
return removeListener;
|
||||
}
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
completedAttemptByManager.delete(manager);
|
||||
let handedOff = false;
|
||||
let cancelled = false;
|
||||
let awaitingPassword = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
awaitingPassword = false;
|
||||
if (cancelled || !password) return;
|
||||
// A failed attempt must not clear the shared password: the config and
|
||||
// the password travel over unordered channels (localStorage vs IPC),
|
||||
// so this attempt may have raced a master-key rotation and the
|
||||
// password may match the config that is still in flight. The attempt
|
||||
// is deduped per [identity, revision], so keeping the password lets
|
||||
// the arriving config trigger a retry instead of stranding the vault.
|
||||
await manager.unlock(password);
|
||||
} catch {
|
||||
// Explicit sync actions surface errors; keep auto-unlock silent.
|
||||
} finally {
|
||||
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
completedAttemptByManager.set(manager, attempt);
|
||||
// Availability events can arrive while no consumer is mounted.
|
||||
// A future mount must read again after this attempt has settled.
|
||||
if (listeners.size === 0) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListener();
|
||||
// React StrictMode can immediately remount the same effect.
|
||||
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
handedOff = true;
|
||||
attemptedAttemptByManager.set(manager, null);
|
||||
// Hand the attempt off: this consumer's in-flight password request is
|
||||
// discarded as cancelled, but other mounted consumers already returned
|
||||
// at the dedup check above, so they must be nudged to retry.
|
||||
for (const notify of listeners) notify();
|
||||
}
|
||||
};
|
||||
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { useCodexConfigModel } from './useCodexConfigModel';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const agent: ExternalAgentConfig = {
|
||||
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
|
||||
env: { CODEX_HOME: '/fixture/custom' },
|
||||
};
|
||||
|
||||
for (const codexRuntime of ['sdk', 'app-server'] as const) {
|
||||
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
|
||||
let resolve!: (result: unknown) => void;
|
||||
const calls: unknown[] = [];
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
|
||||
} } });
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness({ config }: { config: ExternalAgentConfig }) {
|
||||
result = useCodexConfigModel(config, true);
|
||||
return null;
|
||||
}
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
const config = { ...agent, codexRuntime };
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
|
||||
assert.equal(result.model, null);
|
||||
let sent = false;
|
||||
const sending = result.loadModel().then(model => { sent = true; return model; });
|
||||
await Promise.resolve();
|
||||
assert.equal(sent, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
|
||||
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
|
||||
assert.equal(await sending, 'glm-5');
|
||||
assert.equal(result.model, 'glm-5');
|
||||
// A different agent must not inherit the previous home/model or promise.
|
||||
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
|
||||
assert.equal(result.model, null);
|
||||
assert.equal(calls.length, 2);
|
||||
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
|
||||
assert.equal(result.model, null);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('non-Codex agents keep their model flow without probing Codex', async () => {
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
|
||||
assert.equal(await result.loadModel(), null);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Share the config probe between the picker and sends, scoped to this agent. */
|
||||
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
|
||||
const loadModel = useMemo(() => {
|
||||
let pending: Promise<string | null> | undefined;
|
||||
return () => {
|
||||
pending ??= Promise.resolve().then(async () => {
|
||||
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
|
||||
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
|
||||
codexPath: getManualAgentCommand(agent),
|
||||
agentEnv: agent.env,
|
||||
});
|
||||
return info?.customConfig?.model || null;
|
||||
}).catch(() => null);
|
||||
return pending;
|
||||
};
|
||||
// Reopening the panel refreshes config changed outside Netcatty.
|
||||
}, [agent, isVisible]);
|
||||
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
void loadModel().then(model => {
|
||||
if (!cancelled) setResult({ loadModel, model });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isVisible, loadModel]);
|
||||
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { useComposeBarHistory } from './useComposeBarHistory';
|
||||
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('changing workspace focus resets navigation without replacing the draft', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'a1');
|
||||
recordComposeBarHistory('a', 'a2');
|
||||
recordComposeBarHistory('b', 'b1');
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness({ sessionId }: { sessionId: string }) {
|
||||
history = useComposeBarHistory(sessionId);
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { sessionId: 'a' })); });
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'a2');
|
||||
await act(async () => { root.update(React.createElement(Harness, { sessionId: 'b' })); });
|
||||
assert.equal(history.navigate('unsent draft', 'down'), undefined);
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'b1');
|
||||
assert.equal(history.navigate('b1', 'down'), 'unsent draft');
|
||||
recordComposeBarHistory('b', 'later completion');
|
||||
assert.equal(history.navigate('unsent draft', 'up'), 'later completion');
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('an in-progress history walk stays stable when a late send evicts the oldest entry', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
for (let index = 0; index < 100; index += 1) recordComposeBarHistory('a', `h${index}`);
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness() { history = useComposeBarHistory('a'); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
const finish = history.prepareRecord();
|
||||
assert.equal(history.navigate('draft', 'up'), 'h99');
|
||||
finish('h100');
|
||||
assert.equal(history.navigate('h99', 'up'), 'h98');
|
||||
assert.equal(history.navigate('h98', 'down'), 'h99');
|
||||
assert.equal(history.navigate('h99', 'down'), 'draft');
|
||||
assert.equal(history.navigate('draft', 'up'), 'h100');
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
|
||||
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
|
||||
|
||||
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
|
||||
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cursor.current = { index: Infinity, draft: '' };
|
||||
}, []);
|
||||
|
||||
// Keep the existing textarea draft when workspace focus changes, but begin
|
||||
// a fresh history walk in the newly focused session.
|
||||
useLayoutEffect(() => {
|
||||
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
|
||||
reset();
|
||||
}, [reset, restoreDraft, sessionId]);
|
||||
|
||||
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
|
||||
|
||||
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
|
||||
// A late send can evict old entries. Keep one walk stable until the user
|
||||
// returns to the draft or edits, then pick up the latest history.
|
||||
const entries = cursor.current.entries ?? getComposeBarHistory(sessionId);
|
||||
const result = navigateComposeBarHistory({
|
||||
...cursor.current,
|
||||
entries,
|
||||
currentValue,
|
||||
}, direction);
|
||||
if (result) {
|
||||
cursor.current = {
|
||||
index: result.index === entries.length ? Infinity : result.index,
|
||||
draft: result.draft,
|
||||
entries: result.index === entries.length ? undefined : entries,
|
||||
};
|
||||
}
|
||||
return result?.value;
|
||||
}, [sessionId]);
|
||||
|
||||
return { prepareRecord, navigate, reset };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPluginTerminalProviderRefreshCurrent,
|
||||
mergePluginTerminalThemeColors,
|
||||
resolvePluginTerminalTheme,
|
||||
toPluginTerminalTheme,
|
||||
waitForProviderResponse,
|
||||
} from './usePluginTerminalProviders.ts';
|
||||
|
||||
@@ -24,6 +25,15 @@ const baseTheme = {
|
||||
},
|
||||
} as const satisfies TerminalTheme;
|
||||
|
||||
test('theme provider requests keep the existing palette contract with intense color enabled or disabled', () => {
|
||||
for (const foregroundIntense of ['#ff8800', undefined]) {
|
||||
const theme = { ...baseTheme, colors: { ...baseTheme.colors, foregroundIntense } };
|
||||
assert.deepEqual(toPluginTerminalTheme(theme), { type: baseTheme.type, colors: baseTheme.colors });
|
||||
assert.equal(theme.colors.foregroundIntense, foregroundIntense);
|
||||
assert.equal(resolvePluginTerminalTheme(theme, { background: '#112233' }).colors.foregroundIntense, foregroundIntense);
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal theme Providers merge deterministically over the host theme', () => {
|
||||
const merged = mergePluginTerminalThemeColors([
|
||||
{ background: '#102030', cursor: '#abcdef' },
|
||||
|
||||
@@ -51,6 +51,13 @@ interface UsePluginTerminalProvidersOptions extends PluginTerminalProviderRefres
|
||||
getSnapshotState(): Partial<NetcattyTerminalSessionSnapshot>;
|
||||
}
|
||||
|
||||
export function toPluginTerminalTheme(theme: TerminalTheme) {
|
||||
// The preview plugin contract exposes the existing palette only. Keep the
|
||||
// host-only optional color out of its strictly validated request payload.
|
||||
const { foregroundIntense: _foregroundIntense, ...colors } = theme.colors;
|
||||
return { type: theme.type, colors };
|
||||
}
|
||||
|
||||
export function mergePluginTerminalThemeColors(
|
||||
results: readonly PluginTerminalThemeColors[],
|
||||
): PluginTerminalThemeColors {
|
||||
@@ -211,10 +218,7 @@ export function usePluginTerminalProviders(options: UsePluginTerminalProvidersOp
|
||||
if (hasProvider('terminal.theme')) {
|
||||
tasks.push(applyCurrentProviderResponse(waitForProviderResponse(request('terminal.theme', 'provideTheme', {
|
||||
reason,
|
||||
currentTheme: {
|
||||
type: metadata.baseTheme.type,
|
||||
colors: metadata.baseTheme.colors,
|
||||
},
|
||||
currentTheme: toPluginTerminalTheme(metadata.baseTheme),
|
||||
}, PROVIDER_DEADLINE_MS, undefined, controller.signal), controller), isCurrent,
|
||||
(response) => {
|
||||
setThemeColors(mergePluginTerminalThemeColors(response.results.map((result) => result.status === 'ok'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAutoStartRuleBlockReason,
|
||||
isAutoStartProxyReady,
|
||||
isPortForwardingAutoStartEnabled,
|
||||
isPortForwardingReconnectRequested,
|
||||
} from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
@@ -177,6 +178,15 @@ test("reconnect eligibility follows the current auto-start setting", () => {
|
||||
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
getActiveConnection,
|
||||
@@ -103,6 +104,11 @@ export const isPortForwardingAutoStartEnabled = (
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
|
||||
|
||||
export const isPortForwardingReconnectRequested = (
|
||||
rules: PortForwardingRule[],
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
|
||||
|
||||
export const shouldStartPortForwardingAutoStartRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
@@ -110,6 +116,18 @@ export const shouldStartPortForwardingAutoStartRule = (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
/**
|
||||
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
|
||||
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
|
||||
* are also restarted after network restoration.
|
||||
*/
|
||||
export const shouldRestartPortForwardingReconnectRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
@@ -118,7 +136,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
) ?? [];
|
||||
const recoverableRuleIds = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
|
||||
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
|
||||
recoverableRuleIds.add(rule.id);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +202,7 @@ export const runPortForwardingAutoStart = async ({
|
||||
recoveryRuleIds,
|
||||
}: RunPortForwardingAutoStartOptions): Promise<void> => {
|
||||
await syncWithBackend({
|
||||
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
),
|
||||
@@ -197,9 +215,11 @@ export const runPortForwardingAutoStart = async ({
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const autoStartRules = rules.filter((rule) =>
|
||||
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
|
||||
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
|
||||
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
recoveryRuleIds
|
||||
? recoveryRuleIds.has(rule.id) &&
|
||||
isReconnectRecoveryEligible(rule.id) &&
|
||||
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
|
||||
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
);
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
@@ -385,7 +405,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ServerStats {
|
||||
disks: DiskInfo[]; // All mounted disks
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
latencyMs: number | null; // SSH transport ping round-trip to the SSH endpoint
|
||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||
lastUpdated: number | null; // Timestamp of last successful update
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -413,6 +415,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
|
||||
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
|
||||
);
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
@@ -1012,6 +1017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
@@ -1198,6 +1204,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -1254,7 +1261,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1264,7 +1271,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1394,6 +1401,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTabBarPosition = useCallback((value: TabBarPosition) => {
|
||||
const next = normalizeTabBarPosition(value);
|
||||
setTabBarPositionState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
@@ -1865,6 +1880,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
uiLanguage,
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
@@ -1891,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
@@ -2063,7 +2080,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
@@ -2139,7 +2158,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -32,6 +32,11 @@ export const useTrayPanelBackend = () => {
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
@@ -71,6 +76,7 @@ export const useTrayPanelBackend = () => {
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
closeSessionFromTrayPanel,
|
||||
startPortForwardFromTrayPanel,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
|
||||
@@ -111,6 +111,7 @@ export function useVaultImportHandlers({
|
||||
|
||||
const handleImportFileSelected = useCallback(
|
||||
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||||
const preserveProfiles = format === "securecrt" || format === "finalshell";
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (importInFlightRef.current) return;
|
||||
@@ -141,7 +142,9 @@ export function useVaultImportHandlers({
|
||||
? "CSV"
|
||||
: format === "securecrt"
|
||||
? "SecureCRT"
|
||||
: "ssh_config";
|
||||
: format === "finalshell"
|
||||
? "FinalShell"
|
||||
: "ssh_config";
|
||||
const updateProgress = (next: Partial<VaultImportProgress>) => {
|
||||
setImportProgress((current) => ({
|
||||
status: "running",
|
||||
@@ -183,9 +186,9 @@ export function useVaultImportHandlers({
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// Folder imports keep distinct profiles that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
{ collapseDuplicateEndpoints: !preserveProfiles },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
@@ -321,7 +324,7 @@ export function useVaultImportHandlers({
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
let newHosts = preserveProfiles
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
@@ -486,7 +489,7 @@ export function useVaultImportHandlers({
|
||||
importBaselineHosts,
|
||||
importBaselineGroups,
|
||||
result,
|
||||
{ skipDuplicates: format !== "securecrt" },
|
||||
{ skipDuplicates: !preserveProfiles },
|
||||
);
|
||||
newHosts = merged.addedHosts;
|
||||
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
|
||||
|
||||
@@ -25,6 +25,23 @@ test("SecureCRT uses a directory picker and keeps every selected session file",
|
||||
assert.deepEqual(selectVaultImportFiles("csv", files), [files[0]]);
|
||||
});
|
||||
|
||||
test("FinalShell supports conn directory and single JSON file selection", () => {
|
||||
const files = [
|
||||
new File(["{}"], "one.json"),
|
||||
new File(["{}"], "two.json"),
|
||||
];
|
||||
|
||||
assert.deepEqual(getVaultImportPickerMode("finalshell"), {
|
||||
directory: true,
|
||||
multiple: true,
|
||||
});
|
||||
assert.deepEqual(getVaultImportPickerMode("finalshell", "file"), {
|
||||
directory: false,
|
||||
multiple: false,
|
||||
});
|
||||
assert.deepEqual(selectVaultImportFiles("finalshell", files), files);
|
||||
});
|
||||
|
||||
test("vault import destination supports preserve, existing, and new groups", () => {
|
||||
assert.deepEqual(buildVaultImportDestination({ mode: "preserve" }), {
|
||||
mode: "preserve",
|
||||
|
||||
@@ -7,15 +7,16 @@ export type VaultImportDestinationMode = "preserve" | "existing" | "new";
|
||||
|
||||
export function getVaultImportPickerMode(
|
||||
format: VaultImportFormat,
|
||||
secureCrtSource: "folder" | "file" = "folder",
|
||||
source: "folder" | "file" = "folder",
|
||||
): {
|
||||
directory: boolean;
|
||||
multiple: boolean;
|
||||
} {
|
||||
const isSecureCrtFolder = format === "securecrt" && secureCrtSource === "folder";
|
||||
const isBatchFolder = (format === "securecrt" || format === "finalshell")
|
||||
&& source === "folder";
|
||||
return {
|
||||
directory: isSecureCrtFolder,
|
||||
multiple: isSecureCrtFolder,
|
||||
directory: isBatchFolder,
|
||||
multiple: isBatchFolder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +25,9 @@ export function selectVaultImportFiles(
|
||||
files: ArrayLike<File>,
|
||||
): File[] {
|
||||
const selected = Array.from(files);
|
||||
return format === "securecrt" ? selected : selected.slice(0, 1);
|
||||
return format === "securecrt" || format === "finalshell"
|
||||
? selected
|
||||
: selected.slice(0, 1);
|
||||
}
|
||||
|
||||
const normalizeGroup = (raw: string | undefined): string | undefined => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
|
||||
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("mentions carry identity without copying even large note bodies", () => {
|
||||
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
|
||||
const attachment = createVaultNoteAttachment(note)!;
|
||||
assert.equal(attachment.vaultNoteId, note.id);
|
||||
assert.equal(attachment.base64Data, "");
|
||||
assert.equal(attachment.dataUrl, "");
|
||||
assert.ok(JSON.stringify(attachment).length < 400);
|
||||
assert.ok(isInlineTextAttachment(attachment));
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
|
||||
assert.match(prompt, /vault_notes_get/);
|
||||
assert.match(prompt, /latest content/);
|
||||
assert.doesNotMatch(prompt, /secret body/);
|
||||
});
|
||||
|
||||
test("same titled notes keep distinct exact IDs including imported special characters", () => {
|
||||
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
|
||||
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
|
||||
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
|
||||
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
|
||||
assert.equal(metadata.noteId, id);
|
||||
assert.equal(metadata.title, "Same title");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
|
||||
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
|
||||
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
|
||||
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
|
||||
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
|
||||
});
|
||||
|
||||
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
|
||||
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
|
||||
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
|
||||
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
|
||||
assert.match(prompt, /systemctl status nginx/);
|
||||
assert.match(prompt, /empty-note/);
|
||||
assert.match(prompt, /^compare/);
|
||||
});
|
||||
|
||||
test("reference limit reserves replay space and does not count ordinary files", () => {
|
||||
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
|
||||
assert.equal(vaultNoteReferencesFit(notes), false);
|
||||
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
|
||||
assert.ok(accepted.length > 0);
|
||||
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
|
||||
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VaultNote } from "../../domain/models";
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
/** A mention stores only identity; the agent reads current content through the Vault tool. */
|
||||
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
|
||||
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
|
||||
if (!note.id.trim() || note.id.length > 200) return null;
|
||||
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename: title,
|
||||
mediaType: "text/markdown",
|
||||
base64Data: "",
|
||||
dataUrl: "",
|
||||
vaultNoteId: note.id,
|
||||
vaultNoteTitle: title,
|
||||
};
|
||||
}
|
||||
|
||||
export function isVaultNoteAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
|
||||
): boolean {
|
||||
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
|
||||
}
|
||||
|
||||
/** JSON preserves special characters and whitespace in exact note IDs. */
|
||||
export function formatVaultNoteReferences(
|
||||
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
|
||||
): string {
|
||||
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
|
||||
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
|
||||
}
|
||||
|
||||
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
|
||||
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
|
||||
}
|
||||
@@ -1963,3 +1963,107 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
|
||||
test("tab bar position survives settings export and import, with a safe fallback", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.tabBarPosition, "bottom");
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
|
||||
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
|
||||
await applySyncPayload(invalid, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
test(`right-click long press preference survives sync (${enabled})`, async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ rightClickLongPressMenu: enabled }));
|
||||
const payload = buildSyncPayload(vault());
|
||||
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ rightClickLongPressMenu: !enabled }));
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restored.rightClickLongPressMenu, enabled);
|
||||
});
|
||||
}
|
||||
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
|
||||
localStorage.clear();
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async () => sealed,
|
||||
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
|
||||
}]));
|
||||
const plainSettings = buildSyncPayload(vault([]));
|
||||
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
|
||||
const cloud = await buildCloudSyncPayload(vault([]));
|
||||
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
|
||||
await applySyncPayload(cloud, { importVaultData: () => {} });
|
||||
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
||||
await applySyncPayload(plainSettings, { importVaultData: () => {} });
|
||||
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
|
||||
cloud.settings!.ai!.providers![0].customHeaders = {};
|
||||
await applySyncPayload(cloud, { importVaultData: () => {} });
|
||||
assert.deepEqual(read().customHeaders, {});
|
||||
});
|
||||
|
||||
for (const localRestore of [false, true]) {
|
||||
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
let imports = 0;
|
||||
let completed = 0;
|
||||
let commits = 0;
|
||||
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
|
||||
await assert.rejects(localRestore
|
||||
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
|
||||
: applySyncPayload(payload, importers));
|
||||
assert.equal(imports, 0);
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(commits, 0);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
});
|
||||
}
|
||||
|
||||
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
|
||||
localStorage.clear();
|
||||
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
|
||||
const events: string[] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
|
||||
dispatchEvent: () => true,
|
||||
} });
|
||||
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
|
||||
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
|
||||
} };
|
||||
const original = JSON.stringify(payload);
|
||||
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
|
||||
assert.deepEqual(events, ['encrypt', 'import']);
|
||||
assert.equal(JSON.stringify(payload), original);
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
|
||||
});
|
||||
+58
-21
@@ -1,3 +1,5 @@
|
||||
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
|
||||
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
@@ -92,6 +94,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -259,11 +262,11 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'kittyKeyboardProtocolEnabled',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'rightClickBehavior', 'rightClickLongPressMenu', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
|
||||
'autoCloseOnExit', 'disconnectedNoticeMode',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
@@ -315,6 +318,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -399,13 +403,13 @@ const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
|
||||
}
|
||||
return incoming.map((provider) => {
|
||||
if (provider.apiKey != null) return provider;
|
||||
const id = typeof provider.id === 'string' ? provider.id : undefined;
|
||||
const localProvider = id != null ? localById.get(id) : undefined;
|
||||
if (localProvider && typeof localProvider.apiKey === 'string') {
|
||||
return { ...provider, apiKey: localProvider.apiKey };
|
||||
}
|
||||
return provider;
|
||||
return {
|
||||
...provider,
|
||||
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
|
||||
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -542,6 +546,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
@@ -559,7 +565,11 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
|
||||
if (providers) ai.providers = providers.map((provider) => {
|
||||
const next = { ...stripDeviceBoundApiKey(provider) };
|
||||
delete next.customHeaders;
|
||||
return next;
|
||||
});
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
@@ -621,7 +631,12 @@ export async function collectCloudSyncableSettings(): Promise<SyncPayload['setti
|
||||
};
|
||||
|
||||
if (providers) {
|
||||
ai.providers = await Promise.all(providers.map(withPortableApiKey));
|
||||
ai.providers = await Promise.all(providers.map(async (provider) => {
|
||||
const next = await withPortableApiKey(provider);
|
||||
return isRecord(provider.customHeaders)
|
||||
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
|
||||
: next;
|
||||
}));
|
||||
}
|
||||
if (webSearchConfig) {
|
||||
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
|
||||
@@ -658,7 +673,10 @@ function collectLocalBackupSettings(): SyncPayload['settings'] {
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
|
||||
async function applySyncableSettings(
|
||||
settings: NonNullable<SyncPayload['settings']>,
|
||||
preparedProviders: Record<string, unknown>[] | undefined,
|
||||
): Promise<void> {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
@@ -795,6 +813,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.tabBarPosition != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
@@ -816,11 +837,10 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
if (ai.providers != null) {
|
||||
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
|
||||
if (preparedProviders != null) {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
mergeAiProvidersPreservingLocalApiKeys(providers),
|
||||
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
|
||||
);
|
||||
}
|
||||
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
|
||||
@@ -1137,7 +1157,7 @@ export async function buildLocalVaultPayloadAsync(
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
function applyPayload(
|
||||
async function preparePayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: {
|
||||
@@ -1145,11 +1165,20 @@ function applyPayload(
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
currentHosts?: Host[];
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<() => Promise<void>> {
|
||||
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
// Prepare credentials before any vault or settings mutation. Encryption can
|
||||
// fail when local credential storage is unavailable; reuse the result below.
|
||||
const providers = sanitizedPayload.settings?.ai?.providers;
|
||||
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
|
||||
const next = await withLocalEncryptedApiKey(provider);
|
||||
return isRecord(provider.customHeaders)
|
||||
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
|
||||
: next;
|
||||
}));
|
||||
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
|
||||
if (!options.includeLocalOnlyData) {
|
||||
@@ -1181,7 +1210,7 @@ function applyPayload(
|
||||
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
@@ -1192,7 +1221,7 @@ function applyPayload(
|
||||
|
||||
// Apply synced settings
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings);
|
||||
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
@@ -1207,21 +1236,28 @@ function applyPayload(
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
export function prepareSyncPayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options?: {
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
currentHosts?: Host[];
|
||||
},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, {
|
||||
): Promise<() => Promise<void>> {
|
||||
return preparePayloadApply(payload, importers, {
|
||||
includeLocalOnlyData: false,
|
||||
applyPluginSidecars: options?.applyPluginSidecars,
|
||||
currentHosts: options?.currentHosts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySyncPayload(
|
||||
...args: Parameters<typeof prepareSyncPayloadApply>
|
||||
): Promise<void> {
|
||||
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
|
||||
await applyPreparedPayload();
|
||||
}
|
||||
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
@@ -1236,9 +1272,10 @@ export async function prepareLocalVaultPayloadApply(
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
||||
return async () => {
|
||||
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
await applyPreparedPayload();
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ interface AIChatPanelContentProps {
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context. */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
onMentionNote,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSection,
|
||||
@@ -361,6 +364,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={mentionHosts}
|
||||
notes={notes}
|
||||
onMentionNote={onMentionNote}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
AIPanelView,
|
||||
AgentModelPreset,
|
||||
AISessionScope,
|
||||
UploadedFile,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { VaultNote } from '../domain/models';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import {
|
||||
filterAgentModelPresetsForCliVersion,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { toast } from './ui/toast';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
@@ -42,9 +45,10 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
|
||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
isInlineTextAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
||||
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
@@ -597,6 +601,42 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
}, [removeDraftFile, scopeKey, currentAgentId]);
|
||||
|
||||
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
|
||||
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
|
||||
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
|
||||
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
|
||||
toast.warning(t('ai.chat.mentionNoteUnavailable'));
|
||||
return false;
|
||||
}
|
||||
if (!vaultNoteReferencesFit(attachments)) {
|
||||
toast.warning(t('ai.chat.mentionNoteTooMany'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [canMentionNotes, t]);
|
||||
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
|
||||
const mentionNote = useCallback((note: VaultNote) => {
|
||||
if (!canMentionNotes) return;
|
||||
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
|
||||
if (!upload) {
|
||||
toast.error(t('ai.chat.mentionNoteInvalid', {
|
||||
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const existing = currentDraftRef.current?.attachments ?? [];
|
||||
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateDraft(scopeKey, currentAgentId, (current) => ({
|
||||
...current,
|
||||
attachments: [
|
||||
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
|
||||
upload,
|
||||
],
|
||||
}));
|
||||
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) return undefined;
|
||||
flushDraftText();
|
||||
@@ -803,35 +843,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void Promise.resolve(
|
||||
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
|
||||
).then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
|
||||
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
|
||||
currentAgentConfig, isVisible,
|
||||
);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -995,18 +1009,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
if (hasCodexCustomConfig) {
|
||||
if (runtimePresets) {
|
||||
return runtimePresets;
|
||||
}
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
return [];
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
if (runtimePresets) return runtimePresets;
|
||||
const presets = getAgentModelPresets(
|
||||
@@ -1141,6 +1149,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
@@ -1149,18 +1158,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
||||
// Note-only sends (empty text + a mentioned note) still need a usable
|
||||
// session title: fall back to the first mentioned note's title so these
|
||||
// conversations don't all show up as "Untitled" in history.
|
||||
const noteOnlyTitle = attachments.find(
|
||||
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
|
||||
)?.vaultNoteTitle ?? '';
|
||||
const titleText = trimmed || noteOnlyTitle.trim();
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
flushDraftText();
|
||||
@@ -1186,8 +1204,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (sendBridge?.aiSyncWebSearch) {
|
||||
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
let sendSelectedAgentModel = selectedAgentModel;
|
||||
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
// A quick send must await the same config probe as the picker. Its model
|
||||
// also takes precedence over App Server's built-in model catalog.
|
||||
const configModel = await loadCodexConfigModel();
|
||||
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
|
||||
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (runtimeTarget) {
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
@@ -1337,7 +1358,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
autoTitleSession(sessionId, titleText);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
@@ -1360,7 +1381,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
titleText: trimmed,
|
||||
titleText,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
@@ -1371,6 +1392,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
@@ -1467,6 +1489,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
|
||||
|
||||
if (!validateNoteMentions(draft.attachments)) return;
|
||||
const trimmed = draft.text.trim();
|
||||
const attachments = draft.attachments.map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
@@ -1474,15 +1497,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if (!trimmed && !hasTerminalSelectionAttachments) return;
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if (!trimmed && !hasInlineTextAttachments) return;
|
||||
|
||||
const userMessageId = generateId();
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
setSteerWarnings(current => {
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
@@ -1511,7 +1536,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
} finally {
|
||||
setSteeringSessionId(current => current === sessionId ? null : current);
|
||||
}
|
||||
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
}, [
|
||||
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
|
||||
const stopStreamingForSession = useCallback(async (sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
@@ -1707,6 +1733,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
onMentionNote={canMentionNotes ? mentionNote : undefined}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
|
||||
@@ -189,7 +189,7 @@ test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', (
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'glm-5.1',
|
||||
name: 'GLM 5.1',
|
||||
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
|
||||
thinkingLevels: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
}];
|
||||
|
||||
@@ -28,6 +28,15 @@ test('checked targets keep a visible background after the cursor moves away', ()
|
||||
assert.equal(getWorkspaceTargetRowStateClass(true, true), 'bg-primary/20');
|
||||
});
|
||||
|
||||
test('workspace host picker search matches tags through the shared matcher', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /matchesWorkspaceHostPickerQuery/);
|
||||
assert.doesNotMatch(source, /h\.label\?\.toLowerCase\(\)\.includes\(term\)/);
|
||||
});
|
||||
|
||||
test('clicking a target aligns the keyboard cursor with that target', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
|
||||
|
||||
@@ -50,7 +50,7 @@ import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onBuildLocalPayload: () => SyncPayload;
|
||||
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
@@ -63,7 +63,7 @@ interface SyncDashboardProps {
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onBuildLocalPayload,
|
||||
onApplyMigrationPayload,
|
||||
onPrepareMigrationPayload,
|
||||
onApplyPayload,
|
||||
onApplyConvergentPayload,
|
||||
onApplyLocalPayload,
|
||||
@@ -336,7 +336,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
prepared: preparedConvergentMigration,
|
||||
buildCurrentPayload: onBuildPayload,
|
||||
buildPreApplyPayload: onBuildLocalPayload,
|
||||
applyPayload: onApplyMigrationPayload,
|
||||
preparePayloadApply: onPrepareMigrationPayload,
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
});
|
||||
@@ -349,7 +349,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} finally {
|
||||
setConvergentBusy(false);
|
||||
}
|
||||
}, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
|
||||
}, [onPrepareMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
|
||||
|
||||
const handleResolveConvergentConflict = useCallback(async (
|
||||
addressKey: string,
|
||||
@@ -1053,7 +1053,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onBuildLocalPayload: () => SyncPayload;
|
||||
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyConvergentPayload: (
|
||||
payload: SyncPayload,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { matchesWorkspaceHostPickerQuery } from '../lib/searchMatcher';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
@@ -30,13 +31,9 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
const term = search.trim();
|
||||
if (!term) return hosts;
|
||||
return hosts.filter((host) => matchesWorkspaceHostPickerQuery(term, host));
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = useCallback((hostId: string) => {
|
||||
@@ -109,7 +106,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts or tags...' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("resume from another panel is visible while backend verification is pending", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
let settleResume!: (result: { success: boolean }) => void;
|
||||
let resumeCalls = 0;
|
||||
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
|
||||
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
|
||||
resumeTransfer: async () => { resumeCalls += 1; return gate; },
|
||||
} });
|
||||
store.publishOwner("resume-display-test", [{
|
||||
id: "resume-display", fileName: "resume-display.bin",
|
||||
sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle);
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
let resumed!: Promise<void>;
|
||||
await act(async () => { resumed = store.resume("resume-display"); });
|
||||
try {
|
||||
await flushEffects();
|
||||
assert.equal(resumeCalls, 1);
|
||||
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(row);
|
||||
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
|
||||
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
|
||||
} finally {
|
||||
await act(async () => { settleResume({ success: true }); await resumed; });
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
sftpTransferCenterStore,
|
||||
useSftpTransferCenterBadge,
|
||||
useSftpTransferResuming,
|
||||
type SftpTransferCenterSnapshot,
|
||||
} from "../application/state/sftpTransferCenterStore";
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
@@ -355,7 +356,6 @@ function TransferRow({
|
||||
const { t } = useI18n();
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
|
||||
const activeChildren = useMemo(
|
||||
@@ -397,22 +397,8 @@ function TransferRow({
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ["pending", "queued", "transferring"].includes(task.status)
|
||||
&& !task.error;
|
||||
const isResuming = storeResuming
|
||||
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
|
||||
// Clear optimistic click once the store has left the idle-resume surface or failed.
|
||||
useEffect(() => {
|
||||
if (!resumeClicked) return;
|
||||
if (
|
||||
storeResuming
|
||||
|| task.status === "transferring"
|
||||
|| task.status === "completed"
|
||||
|| task.status === "failed"
|
||||
|| task.status === "cancelled"
|
||||
|| !!task.error
|
||||
) {
|
||||
setResumeClicked(false);
|
||||
}
|
||||
}, [resumeClicked, storeResuming, task.status, task.error]);
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
|
||||
// Orphaned tasks after app restart (interrupted / attention / paused without a
|
||||
// live panel owner) must still expose resume/cancel from the global center.
|
||||
@@ -467,7 +453,6 @@ function TransferRow({
|
||||
}));
|
||||
};
|
||||
const resumeTask = () => {
|
||||
setResumeClicked(true);
|
||||
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
|
||||
// Only force-open the panel when the row still needs a live owner/adoption
|
||||
// (e.g. conflict) — not on every remote-to-remote resume click.
|
||||
|
||||
@@ -64,4 +64,6 @@ test("large transfer histories mount only visible rows and can scroll to the las
|
||||
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
|
||||
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import { Switch } from "./ui/switch";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
@@ -547,6 +548,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
protocol: 'ssh' as const,
|
||||
@@ -994,6 +996,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</HostDetailsSection>
|
||||
</>)}
|
||||
|
||||
<HostNotesEditor
|
||||
panelKey={groupPath}
|
||||
value={form.notes ?? ""}
|
||||
onChange={(notes) => update("notes", notes)}
|
||||
/>
|
||||
|
||||
{/* Add Protocol Button — always at the bottom */}
|
||||
{addableProtocols.length > 0 && (
|
||||
<Dropdown open={addProtocolOpen} onOpenChange={setAddProtocolOpen}>
|
||||
|
||||
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user