mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
396
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31700d6847 | ||
|
|
7d3cd14e0b | ||
|
|
a1d0397ab7 | ||
|
|
1252ec3835 | ||
|
|
5fbfdc749f | ||
|
|
b0e6cdbeb9 | ||
|
|
ed94b4173c | ||
|
|
7e50cc3218 | ||
|
|
ca37599be7 | ||
|
|
294a9aca69 | ||
|
|
252d2026e0 | ||
|
|
e8b7a032e9 | ||
|
|
eca4577dbd | ||
|
|
259c1970f0 | ||
|
|
660d8483f5 | ||
|
|
ee46f36088 | ||
|
|
249211d893 | ||
|
|
aedf99794c | ||
|
|
059586c840 | ||
|
|
05cffda7be | ||
|
|
2cfe2a05ea | ||
|
|
7ed7250f34 | ||
|
|
2ce0164fa9 | ||
|
|
2e55b9348f | ||
|
|
1e79dadbe5 | ||
|
|
52e15f8df7 | ||
|
|
7497e64ab4 | ||
|
|
9888f07cb2 | ||
|
|
293ca1098e | ||
|
|
35a47fe95a | ||
|
|
82add1bda6 | ||
|
|
239804bf62 | ||
|
|
460d9bb7c6 | ||
|
|
b7996c58e3 | ||
|
|
ab50ad93b7 | ||
|
|
c86116ac08 | ||
|
|
5ac8c8bf1f | ||
|
|
b99de934a3 | ||
|
|
8e88b99131 | ||
|
|
d7122683d2 | ||
|
|
2c67e3c3ce | ||
|
|
7e9e9b53eb | ||
|
|
e8bbc9a5c9 | ||
|
|
3de34cae5d | ||
|
|
7851ddb01e | ||
|
|
de517a6237 | ||
|
|
aca81953fe | ||
|
|
9a484ba3cb | ||
|
|
dfc7961b68 | ||
|
|
41d2a2d4af | ||
|
|
af9ee5d697 | ||
|
|
2aae278d53 | ||
|
|
d9632ab985 | ||
|
|
82ee429eab | ||
|
|
b70abfd70d | ||
|
|
1fe313b229 | ||
|
|
fd559eb095 | ||
|
|
3f2b877ba6 | ||
|
|
707a8749c9 | ||
|
|
2d5f90ac2e | ||
|
|
d0e024625c | ||
|
|
9a8f9bb0c6 | ||
|
|
b512133209 | ||
|
|
83ee53ba96 | ||
|
|
fa07263628 | ||
|
|
08c0ac4f59 | ||
|
|
d298275378 | ||
|
|
cdf9b334d5 | ||
|
|
158f204baa | ||
|
|
1c34384651 | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd | ||
|
|
cc001c9a4b | ||
|
|
b81b27f817 | ||
|
|
3a2bf6498d | ||
|
|
53a4bf29b0 | ||
|
|
0ed481c397 | ||
|
|
e85c1fea30 | ||
|
|
6c2bf43ee5 | ||
|
|
abdf254702 | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec | ||
|
|
71373d1ad1 | ||
|
|
b4e056dd4a | ||
|
|
d5c7d3fbf4 | ||
|
|
1ab07ffee4 | ||
|
|
4af4af274b | ||
|
|
e8ad5b3d29 | ||
|
|
b7953a6b26 | ||
|
|
0a01af5144 | ||
|
|
848f201cf9 | ||
|
|
5c901c6b04 | ||
|
|
de3410f6e1 | ||
|
|
a76971e2c5 | ||
|
|
84bf1cbfff | ||
|
|
d4d07d48af | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
1ff40e859c | ||
|
|
edf07fc74e | ||
|
|
6a1bea2bce | ||
|
|
7a1f415a91 | ||
|
|
745f6a3120 | ||
|
|
19faa554a4 | ||
|
|
c0a66f1e88 | ||
|
|
6a79400112 | ||
|
|
13bcf47282 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 | ||
|
|
e5abae559c | ||
|
|
d653180c3c | ||
|
|
0006634f6b | ||
|
|
729ffcf162 | ||
|
|
e642c4953f | ||
|
|
cb737ba0c9 | ||
|
|
c6a1f11fc9 | ||
|
|
483410b132 | ||
|
|
5ed58345aa | ||
|
|
5f29d91499 | ||
|
|
5d9c8f29de | ||
|
|
201ebcb21a | ||
|
|
6ac6c3dbfa | ||
|
|
11f2b7dbd8 | ||
|
|
af574078e7 | ||
|
|
355d53abe3 | ||
|
|
c89e4c7246 | ||
|
|
282a557f67 | ||
|
|
1ee0f8441c | ||
|
|
c396fc78ef | ||
|
|
c48514381e | ||
|
|
5ae5965556 | ||
|
|
0050e895df | ||
|
|
060fbbb2d1 | ||
|
|
8c64280d87 | ||
|
|
422bf120a9 | ||
|
|
555e911887 | ||
|
|
74b7ce6c8c | ||
|
|
3b78e097e6 | ||
|
|
7cb3e2e2ad | ||
|
|
6052cbbd1f | ||
|
|
ecd988ea4c | ||
|
|
258d72bdca | ||
|
|
157e5f03f0 | ||
|
|
d2b1d5fa20 | ||
|
|
cc6ba30891 | ||
|
|
2fefdf6f0c | ||
|
|
01d6c4ceaf | ||
|
|
9b7d2a6a34 | ||
|
|
d79a4de401 | ||
|
|
9a17691c32 | ||
|
|
c22bf0ab9b | ||
|
|
494cd57e25 | ||
|
|
f0169ad632 | ||
|
|
df74164d54 | ||
|
|
90ab87efcc | ||
|
|
4cd6a7191d | ||
|
|
3a0528bbd4 | ||
|
|
c349d4716f | ||
|
|
f7483f0593 | ||
|
|
1209bfa61c | ||
|
|
6baa87e3c3 | ||
|
|
a1d1115e0a | ||
|
|
16376a2117 | ||
|
|
f0833049e0 | ||
|
|
75c155b748 | ||
|
|
6fcd6a3ec3 | ||
|
|
de6d1a28ba | ||
|
|
b2ed83ae51 | ||
|
|
f91b2cd04e | ||
|
|
1d10d1679e | ||
|
|
8d2058267a | ||
|
|
8020eaa427 | ||
|
|
3298a70f32 | ||
|
|
e7202f392a | ||
|
|
9423a1d1cd | ||
|
|
50ca182e1e | ||
|
|
04a63c653a | ||
|
|
e2f499b0f9 | ||
|
|
5805d15746 | ||
|
|
bd8476fb34 | ||
|
|
c74daf0102 | ||
|
|
f1425c8d5a | ||
|
|
fc55e9ac17 | ||
|
|
025ce741c2 | ||
|
|
323ff0b16a | ||
|
|
6ece7e8222 | ||
|
|
e3894eb850 | ||
|
|
38c7376515 | ||
|
|
1b8228b23d | ||
|
|
b2c518a6f1 | ||
|
|
ea36f46d1d | ||
|
|
6d0d39e678 | ||
|
|
08089323a9 | ||
|
|
91db655a1a | ||
|
|
d51cd2714d | ||
|
|
3fd954d2bf | ||
|
|
ff28c9b412 | ||
|
|
7006fa214e | ||
|
|
459f3a5278 | ||
|
|
6c8edddfa8 | ||
|
|
5af2c86f8f | ||
|
|
7b1151f976 | ||
|
|
d21016b91a | ||
|
|
b62a0b3a8f | ||
|
|
3560a04fa6 | ||
|
|
a373f57905 | ||
|
|
e376190a70 | ||
|
|
037a2f651b | ||
|
|
1a4a022a8e | ||
|
|
fd177e8453 | ||
|
|
dee468dcfa | ||
|
|
c666fdb156 | ||
|
|
b4111cf970 | ||
|
|
69497ccc6a | ||
|
|
078353dc87 | ||
|
|
fb7adc3bfc | ||
|
|
8d6ef72006 | ||
|
|
85683757e5 | ||
|
|
7224ad37c8 | ||
|
|
598f5cf796 | ||
|
|
ed92944af0 | ||
|
|
e2b3daac9a | ||
|
|
85bddba538 | ||
|
|
66e87a95f1 | ||
|
|
7cce9c9dbb | ||
|
|
954b60acf2 | ||
|
|
73284582f5 | ||
|
|
33853398d4 | ||
|
|
9497cd65fc | ||
|
|
95a0186d90 | ||
|
|
626e0ce5bd | ||
|
|
71d378c9b8 | ||
|
|
105b7f16bc | ||
|
|
ec11989d52 | ||
|
|
fd3e82386a | ||
|
|
2b120c0d41 | ||
|
|
318577cd72 | ||
|
|
6398360854 | ||
|
|
e3b2e82f88 | ||
|
|
9e07e9e7b9 | ||
|
|
0261931f95 | ||
|
|
62e9c9ed12 | ||
|
|
1b4e826e15 | ||
|
|
f469513579 | ||
|
|
707972d976 | ||
|
|
8573027e61 | ||
|
|
ff65339e04 | ||
|
|
a70c43e302 | ||
|
|
7b3b8214a9 | ||
|
|
cb06857480 | ||
|
|
0982c213e7 | ||
|
|
0d21e37a19 | ||
|
|
9082aa08a4 | ||
|
|
dffcefc308 | ||
|
|
6bee56220c | ||
|
|
0fa0c6a0a4 | ||
|
|
12a3ae07ef | ||
|
|
ec2f0d0bce | ||
|
|
3738565b1b | ||
|
|
d98a22f49b | ||
|
|
08009d4ce0 | ||
|
|
f841ab4778 | ||
|
|
2fa13257e7 | ||
|
|
8ef37454ae | ||
|
|
7e4b3c293a | ||
|
|
bf9fa571ee | ||
|
|
477bc0c4ec | ||
|
|
741299f71d | ||
|
|
349755aaf7 | ||
|
|
199afd56f6 | ||
|
|
246fc39536 | ||
|
|
c16f0c43d4 | ||
|
|
4df94736d0 | ||
|
|
91c3defe66 | ||
|
|
faf74ae9be | ||
|
|
cbace2f2ac | ||
|
|
a52726aef4 | ||
|
|
605dc94b13 | ||
|
|
9e74618f2e | ||
|
|
b46910d46e | ||
|
|
d8ea77c28b | ||
|
|
210879b848 | ||
|
|
db0238ce6e | ||
|
|
6f30bbefb5 | ||
|
|
843c81ca44 | ||
|
|
f1e59913c2 | ||
|
|
a95eb62cb1 | ||
|
|
7205c40a7c | ||
|
|
7cf8ebac7e | ||
|
|
48c721bf43 | ||
|
|
756f332db5 | ||
|
|
2ee55d5b4b | ||
|
|
2e588630a1 | ||
|
|
e9cc0d54d2 | ||
|
|
57cd40a192 | ||
|
|
e5dc30ba2f | ||
|
|
e9d2963fe5 | ||
|
|
d0af56b4dd | ||
|
|
09f7fcf248 | ||
|
|
9e63ffd99e | ||
|
|
f24b5f4202 | ||
|
|
26278a8694 | ||
|
|
e10e5ccbc2 | ||
|
|
d9ca951cc9 | ||
|
|
00ec0d04c4 | ||
|
|
dd5749460f | ||
|
|
00c9cf440f | ||
|
|
6cdae7af9c | ||
|
|
3b1a4adc82 | ||
|
|
ac65d465cf | ||
|
|
2738adb2f8 | ||
|
|
688f544034 | ||
|
|
f9ad1ddfe0 | ||
|
|
a0d032ec3c | ||
|
|
e9b0dadd2a | ||
|
|
be511f8546 | ||
|
|
214bc8e7f1 | ||
|
|
758029bc29 | ||
|
|
3e17cdd62e | ||
|
|
8b5db49001 | ||
|
|
87f794d17d | ||
|
|
cb35353fde | ||
|
|
ce86e8e426 | ||
|
|
0714ca5ee0 | ||
|
|
b081673c2b | ||
|
|
131ed03ffa | ||
|
|
1fc98cd654 | ||
|
|
28f7193c4a | ||
|
|
dbdc8631dd | ||
|
|
a05e004259 | ||
|
|
cae4169948 | ||
|
|
7ec9cd652b | ||
|
|
3987e465b6 | ||
|
|
d8761c156e | ||
|
|
47bb5c2b55 | ||
|
|
dc05697ede | ||
|
|
51d4d97419 | ||
|
|
530f57c95c | ||
|
|
da0938c209 | ||
|
|
9e6846e0d5 | ||
|
|
0b96d70702 | ||
|
|
e7d7a12a91 | ||
|
|
86d8c5279d | ||
|
|
e0cb6034fe | ||
|
|
b54c5e3a9b | ||
|
|
818c6fd551 | ||
|
|
15b16ab7b2 | ||
|
|
f83b5f6693 | ||
|
|
8017c144fe | ||
|
|
462ffbb182 | ||
|
|
4288948d94 | ||
|
|
66deeb8dd9 | ||
|
|
f0b3bca13f | ||
|
|
6c05f0b450 | ||
|
|
aaced70fc6 | ||
|
|
891faff31c | ||
|
|
08557ff596 | ||
|
|
7f75662c9f | ||
|
|
561f4b3c71 | ||
|
|
6ea6c23e88 | ||
|
|
55c2b18738 | ||
|
|
8be56264fc | ||
|
|
d7a0c8ed8d | ||
|
|
65afea01e0 | ||
|
|
fe2f14f032 | ||
|
|
1a9c57b0a9 | ||
|
|
058727b0d9 | ||
|
|
35ed0b6dbd | ||
|
|
ab80d04039 | ||
|
|
aaac962179 | ||
|
|
5d61462b20 | ||
|
|
8cc8d938b8 | ||
|
|
1babda6fa9 | ||
|
|
0eebc77b1b | ||
|
|
7069bdb1c8 | ||
|
|
10ad14223f | ||
|
|
f7f0d804af | ||
|
|
4ba196510d | ||
|
|
fd54339367 | ||
|
|
28b9a8db89 | ||
|
|
565ab504f4 | ||
|
|
9958ee5097 |
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"
|
||||
@@ -1201,8 +1202,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Freeze trusted helper
|
||||
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
|
||||
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
|
||||
run: *freeze_ai_helper
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
@@ -1695,6 +1695,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 \
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
@@ -70,3 +73,26 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|---------|-------------------|--------|
|
||||
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
|
||||
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
|
||||
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
|
||||
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
|
||||
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
|
||||
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
|
||||
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime (internal preview)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
@@ -81,6 +82,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
|
||||
@@ -220,6 +220,30 @@ Windows リリースは未署名の場合があります。
|
||||
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### Scoop(Windows x64、コミュニティ管理)
|
||||
|
||||
[Scoop](https://scoop.sh/) をインストール済みの場合、コミュニティが管理する [lemon バケット](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) から Netcatty をインストールできます。このバケットは Netcatty の公式 GitHub Releases からインストーラーをダウンロードします:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新またはアンインストールの前に Netcatty を終了してください:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
アンインストールする場合:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
これはサードパーティ製パッケージであり、Netcatty の公式バケットではありません。更新は GitHub Releases より遅れる場合があります。現在は x64 のみに対応しています。設定は `%APPDATA%\netcatty` に保存され、更新や通常のアンインストール後も残ります。`scoop uninstall netcatty --purge` はこれらの設定も削除します。この方法でインストールした場合は、Scoop で更新を管理してください。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
|
||||
|
||||
@@ -127,6 +127,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
@@ -221,6 +222,30 @@ application and integration are complete.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
Exit Netcatty before updating or uninstalling:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
This is a third-party package, not an official Netcatty bucket, and updates may lag behind GitHub Releases. It currently supports x64 only. Settings remain in `%APPDATA%\netcatty` across updates and normal uninstalls; `scoop uninstall netcatty --purge` also deletes those settings. Use Scoop to manage updates for this installation.
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 发布文件可能仍未签名。
|
||||
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### Scoop(Windows x64,社区维护)
|
||||
|
||||
安装 [Scoop](https://scoop.sh/) 后,可以通过社区维护的 [lemon 软件源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安装 Netcatty。该软件源从 Netcatty 官方 GitHub Releases 下载安装文件:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或卸载前,请先退出 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
卸载:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
这是第三方软件包,并非 Netcatty 官方软件源,更新可能晚于 GitHub Releases。目前仅支持 x64。设置保存在 `%APPDATA%\netcatty`,更新和普通卸载会保留这些设置;`scoop uninstall netcatty --purge` 还会删除这些设置。通过此方式安装后,请使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
|
||||
|
||||
@@ -219,6 +219,30 @@ Windows 發行檔可能仍未簽章。
|
||||
|
||||
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
|
||||
|
||||
### Scoop(Windows x64,社群維護)
|
||||
|
||||
安裝 [Scoop](https://scoop.sh/) 後,可以透過社群維護的 [lemon 軟體來源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安裝 Netcatty。此軟體來源從 Netcatty 官方 GitHub Releases 下載安裝檔:
|
||||
|
||||
```powershell
|
||||
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
|
||||
scoop install lemon/netcatty
|
||||
```
|
||||
|
||||
更新或解除安裝前,請先結束 Netcatty:
|
||||
|
||||
```powershell
|
||||
scoop update
|
||||
scoop update netcatty
|
||||
```
|
||||
|
||||
解除安裝:
|
||||
|
||||
```powershell
|
||||
scoop uninstall netcatty
|
||||
```
|
||||
|
||||
這是第三方套件,並非 Netcatty 官方軟體來源,更新可能晚於 GitHub Releases。目前僅支援 x64。設定儲存在 `%APPDATA%\netcatty`,更新和一般解除安裝會保留這些設定;`scoop uninstall netcatty --purge` 也會刪除這些設定。透過此方式安裝後,請使用 Scoop 管理更新。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
|
||||
|
||||
@@ -1,57 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
|
||||
|
||||
test('batch tab close removes standalone sessions in one state update', async () => {
|
||||
const closedSessionBatches: string[][] = [];
|
||||
const probedSessionBatches: string[][] = [];
|
||||
const closeTabsInFlightRef = { current: false };
|
||||
const sessions = [
|
||||
{ id: 's1', protocol: 'ssh' },
|
||||
{ id: 's2', protocol: 'ssh' },
|
||||
{ id: 's3', protocol: 'ssh' },
|
||||
];
|
||||
function setup() {
|
||||
const closed: string[] = [];
|
||||
const probes: string[][] = [];
|
||||
const editors = [{ id: 'e', sessionId: 'connection', sftpTabId: 'pane' }];
|
||||
let active = 's1';
|
||||
const ctx = {
|
||||
closeLogView: (id: string) => { closed.push(id); },
|
||||
closeSessions: (ids: string[]) => { closed.push(...ids); },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: (id: string) => { closed.push(id); },
|
||||
confirmIfBusyLocalTerminal: async (ids: string[]) => { probes.push(ids); return true; },
|
||||
logViews: [{ id: 'log' }],
|
||||
sessions: [{ id: 's1', workspaceId: null as string | null }, { id: 's2', workspaceId: null as string | null }],
|
||||
workspaces: [{ id: 'ws' }],
|
||||
activeTabStore: { getActiveTabId: () => active, setActiveTabId: (id: string) => { active = id; } },
|
||||
editorTabStore: { getTabs: () => editors },
|
||||
pluginViewTabStore: {
|
||||
getTab: (id: string) => id === 'plugin',
|
||||
close: (id: string) => { closed.push(id); },
|
||||
},
|
||||
handleRequestCloseEditorTabRef: { current: async (id: string) => {
|
||||
closed.push(`editor:${id}`);
|
||||
editors.splice(editors.findIndex((editor) => editor.id === id), 1);
|
||||
return true;
|
||||
} },
|
||||
findEditorSftpOwnerTabId: (_connection?: string, _pane?: string): string | null => null,
|
||||
orderedTabsWithEditors: ['s1', 's2', 'ws', 'editor:e', 'log', 'plugin'],
|
||||
};
|
||||
return { ctx, closed, probes, run: (ids: string[]) => closeTabsBatchImpl(() => ctx, ids) };
|
||||
}
|
||||
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => {},
|
||||
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
|
||||
closeTabsInFlightRef,
|
||||
closeWorkspace: () => {},
|
||||
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
|
||||
probedSessionBatches.push(sessionIds);
|
||||
return true;
|
||||
},
|
||||
logViews: [],
|
||||
sessions,
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1', 's2', 's3'],
|
||||
);
|
||||
|
||||
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.equal(closeTabsInFlightRef.current, false);
|
||||
assert.equal(result, true);
|
||||
test('batch closes sessions, workspaces, editors, logs and plugins and selects surviving tab', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
assert.equal(await run(['s1', 'ws', 'editor:e', 'log', 'plugin']), true);
|
||||
assert.deepEqual(closed, ['editor:e', 's1', 'ws', 'log', 'plugin']);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's2');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
test('batch tab close reports cancellation before mutating any tab', async () => {
|
||||
let mutated = false;
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => { mutated = true; },
|
||||
closeSessions: () => { mutated = true; },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: () => { mutated = true; },
|
||||
confirmIfBusyLocalTerminal: async () => false,
|
||||
logViews: [],
|
||||
sessions: [{ id: 's1', protocol: 'local' }],
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1'],
|
||||
);
|
||||
test('busy cancellation happens before any editor or other tab mutation', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.confirmIfBusyLocalTerminal = async () => false;
|
||||
assert.equal(await run(ctx.orderedTabsWithEditors), false);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(mutated, false);
|
||||
test('cancelled editor preserves its owner and active focus', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.deepEqual(closed, ['s2', 'ws', 'log', 'plugin']);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
});
|
||||
|
||||
test('editor outside close-left range preserves its owner', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
await run(['s1']);
|
||||
assert.deepEqual(closed, []);
|
||||
});
|
||||
|
||||
test('preserved workspace containing an editor owner remains focused', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
ctx.sessions[0].workspaceId = 'ws';
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.activeTabStore.setActiveTabId('ws');
|
||||
await run(['ws']);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'ws');
|
||||
});
|
||||
|
||||
test('cancelled editor becomes focus destination when other tabs close', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'editor:e');
|
||||
});
|
||||
|
||||
for (const phase of ['busy', 'editor'] as const) {
|
||||
test(`overlapping batch is ignored during ${phase} prompt, including editor-only batches`, async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
let release!: (result: boolean) => void;
|
||||
const pending = new Promise<boolean>((resolve) => { release = resolve; });
|
||||
if (phase === 'busy') ctx.confirmIfBusyLocalTerminal = () => pending;
|
||||
else ctx.handleRequestCloseEditorTabRef.current = () => pending;
|
||||
const first = run(phase === 'busy' ? ['s1'] : ['editor:e']);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, true);
|
||||
assert.equal(await run(['s2', 'plugin']), false);
|
||||
assert.deepEqual(closed, []);
|
||||
release(false);
|
||||
await first;
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('failed prompt releases guard so next batch can run', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => { throw new Error('save failed'); };
|
||||
await assert.rejects(run(['editor:e']), /save failed/);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
assert.equal(await run(['s1']), true);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -398,3 +399,69 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
|
||||
assert.equal(hosts[0].password, 'old-password');
|
||||
assert.deepEqual(errors, ['Request not found']);
|
||||
});
|
||||
|
||||
test('local host without explicit shell resolves the configured Settings shell', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
|
||||
const discoveredShells = [
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
|
||||
];
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session';
|
||||
},
|
||||
discoveredShells,
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
|
||||
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
|
||||
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
|
||||
});
|
||||
|
||||
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = {
|
||||
...baseHost,
|
||||
id: 'local-2',
|
||||
hostname: 'localhost',
|
||||
protocol: 'local',
|
||||
localShell: 'pwsh',
|
||||
};
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session-2';
|
||||
},
|
||||
discoveredShells: [],
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'pwsh');
|
||||
assert.equal(connectedHosts[0].localShellName, undefined);
|
||||
});
|
||||
@@ -698,3 +698,134 @@ test('connection log host snapshot includes custom host icon fields', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleOpacity hotkey invokes toggleWindowOpacity', () => {
|
||||
let called = false;
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWindowOpacity: () => { called = true; },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}),
|
||||
'toggleOpacity',
|
||||
{ key: 'u', code: 'KeyU', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'IME process key with its original physical code', key: 'Process', keyCode: 229, isComposing: false, expected: false },
|
||||
{ name: 'key during composition', key: 'U', keyCode: 85, isComposing: true, expected: false },
|
||||
{ name: 'ordinary shortcut', key: 'U', keyCode: 85, isComposing: false, expected: true },
|
||||
]) {
|
||||
test(`transparency shortcut respects ${scenario.name}`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = {
|
||||
...scenario,
|
||||
code: 'KeyU',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => actions.push(action),
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), event);
|
||||
assert.deepEqual(actions, scenario.expected ? ['toggleOpacity'] : []);
|
||||
assert.equal(prevented, scenario.expected);
|
||||
});
|
||||
}
|
||||
for (const scenario of [
|
||||
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
|
||||
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
|
||||
{ name: 'hidden active session', active: 'hidden', sessions: [{ id: 'one' }, { id: 'two' }, { id: 'hidden', hiddenFromTabs: true }], available: true, expected: null },
|
||||
{ name: 'non-terminal tab', active: 'vault', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: null },
|
||||
{ name: 'workspace', active: 'workspace', sessions: [{ id: 'one', workspaceId: 'workspace' }, { id: 'two', workspaceId: 'workspace' }], available: false, expected: 'workspace' },
|
||||
]) {
|
||||
for (const scheme of ['pc', 'mac'] as const) {
|
||||
test(`broadcast shortcut ${scheme}: ${scenario.name} only consumes an executed toggle`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
let prevented = 0;
|
||||
let stopped = 0;
|
||||
let globalEnabled = false;
|
||||
let workspaceEnabled = false;
|
||||
const toggles: string[] = [];
|
||||
const event = {
|
||||
key: 'b', code: 'KeyB', ctrlKey: scheme === 'pc', metaKey: scheme === 'mac',
|
||||
altKey: false, shiftKey: false, target, composedPath: () => [target],
|
||||
preventDefault: () => { prevented += 1; },
|
||||
stopPropagation: () => { stopped += 1; },
|
||||
} as unknown as KeyboardEvent;
|
||||
const actionCtx = {
|
||||
activeTabStore: { getActiveTabId: () => scenario.active },
|
||||
editorTabs: [], orderedTabs: [], settings: {}, toEditorTabId: (id: string) => id,
|
||||
sessions: scenario.sessions,
|
||||
workspaces: scenario.active === 'workspace' ? [{ id: 'workspace' }] : [],
|
||||
canUseGlobalBroadcast: scenario.available,
|
||||
toggleGlobalBroadcast: () => { globalEnabled = !globalEnabled; toggles.push('global'); },
|
||||
toggleBroadcast: (id: string) => { assert.equal(id, 'workspace'); workspaceEnabled = !workspaceEnabled; toggles.push('workspace'); },
|
||||
};
|
||||
const invoke = () => handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false, closeTabKeyStr: '', hotkeyScheme: scheme,
|
||||
keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
|
||||
executeHotkeyAction: (action: string, e: KeyboardEvent) => executeHotkeyActionImpl(() => actionCtx, action, e),
|
||||
}), event);
|
||||
invoke();
|
||||
assert.equal(globalEnabled, scenario.expected === 'global');
|
||||
assert.equal(workspaceEnabled, scenario.expected === 'workspace');
|
||||
invoke();
|
||||
assert.equal(globalEnabled, false);
|
||||
assert.equal(workspaceEnabled, false);
|
||||
assert.deepEqual(toggles, scenario.expected ? [scenario.expected, scenario.expected] : []);
|
||||
assert.equal(prevented, scenario.expected ? 2 : 0);
|
||||
assert.equal(stopped, scenario.expected ? 2 : 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { focusEditorInWindow } from '../state/editorWindowClient';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
@@ -71,6 +72,16 @@ export function AppActiveTabChrome({
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorTabId(activeTabId)) return;
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
if (!editorId) return;
|
||||
const tab = editorTabs.find((entry) => entry.id === editorId);
|
||||
if (tab?.placement !== 'window') return;
|
||||
void focusEditorInWindow(editorId);
|
||||
setActiveTabId('vault');
|
||||
}, [activeTabId, editorTabs, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
|
||||
+132
-51
@@ -3,14 +3,18 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
@@ -151,7 +155,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;
|
||||
}
|
||||
@@ -201,7 +205,10 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleConnectToHostImpl) instead of
|
||||
// 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -299,6 +306,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
// IME-filtered keys retain their physical code (e.g. Linux Unicode entry).
|
||||
// Let composition handle them instead of treating them as opacity toggles.
|
||||
if (binding.action === 'toggleOpacity' && (e.isComposing || e.keyCode === 229 || e.key === 'Process')) {
|
||||
return;
|
||||
}
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
@@ -310,6 +322,16 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unavailable broadcast action must leave the chord to the terminal
|
||||
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
|
||||
if (binding.action === 'broadcast') {
|
||||
if (executeHotkeyAction(binding.action, e) === true) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
@@ -522,7 +544,11 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
||||
try {
|
||||
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
||||
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
||||
allowSshProbe = shouldProbeSessionCwd({
|
||||
isNetworkDevice: false,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
} catch {
|
||||
allowSshProbe = false;
|
||||
}
|
||||
@@ -677,52 +703,90 @@ 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) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -912,6 +976,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleOpacity':
|
||||
toggleWindowOpacity();
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
@@ -922,12 +989,14 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
return true;
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
session.id === currentId && !session.workspaceId && !session.hiddenFromTabs
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
case 'openSettings':
|
||||
handleOpenSettingsRef.current();
|
||||
@@ -1047,11 +1116,15 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1081,7 +1154,10 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleCreateLocalTerminalImpl)
|
||||
// instead of 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1129,8 +1205,13 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
// Gates the protocol picker (legacy name kept for its existing wiring).
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol;
|
||||
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
|
||||
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
|
||||
// serial hosts must always launch their own transport, never offer
|
||||
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
|
||||
// without an explicit protocol are legacy SSH entries.
|
||||
const effective = resolveEffectiveHost(host);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ test('serial targets use the serial editor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('local targets use the local shell editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({
|
||||
mode: 'edit',
|
||||
openedHost: host({ protocol: 'local' }),
|
||||
requestId: 3,
|
||||
}),
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
test('new and ssh targets use the standard editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
|
||||
@@ -71,8 +82,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
|
||||
|
||||
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
|
||||
assert.match(source, /\[&>\*\]:pointer-events-auto/);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -81,7 +92,7 @@ test('editor host panels share vault resize width persistence', () => {
|
||||
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
|
||||
assert.match(source, /resizable:\s*true/);
|
||||
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import HostDetailsPanel from '../../components/HostDetailsPanel';
|
||||
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
|
||||
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
|
||||
import { PortalContainerProvider } from '../../components/ui/portal-container';
|
||||
import { resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
@@ -22,14 +23,16 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: 'standard';
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -160,7 +163,18 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
|
||||
<LocalShellHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
groups={groups}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
layout="overlay"
|
||||
className="pointer-events-auto"
|
||||
{...hostPanelResizeProps}
|
||||
/>
|
||||
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -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,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("deep-link tab names are applied to terminal sessions", () => {
|
||||
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
|
||||
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
|
||||
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
|
||||
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
|
||||
);
|
||||
assert.match(source, /label: tabName/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { createHostTerminalSession } from '../state/sessionFactories';
|
||||
|
||||
// Exercise the production application callback, including its async command
|
||||
// resolution boundary, before the state hook snapshots the shell type.
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const script of [false, true]) {
|
||||
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
|
||||
let session: ReturnType<typeof createHostTerminalSession> | undefined;
|
||||
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
|
||||
(value: unknown) => value,
|
||||
() => script,
|
||||
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
|
||||
session = createHostTerminalSession('snippet-local', hosts[0]);
|
||||
},
|
||||
async () => 'echo hello',
|
||||
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: 'cmd' },
|
||||
resolveShellSetting: () => ({ command: 'cmd.exe' }),
|
||||
}),
|
||||
);
|
||||
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
|
||||
assert.equal(session?.protocol, 'local');
|
||||
assert.equal(session?.localShell, 'cmd.exe');
|
||||
assert.equal(session?.shellType, 'cmd');
|
||||
});
|
||||
}
|
||||
@@ -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,12 @@ 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 { installEditorWindowSourceListeners } from '../state/editorWindowClient';
|
||||
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
pluginViewTabStore,
|
||||
resolveBatchTabCloseFocus,
|
||||
usePluginViewTabs,
|
||||
} from '../state/pluginViewTabStore';
|
||||
import {
|
||||
@@ -37,13 +38,15 @@ import {
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from '../../domain/models';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
|
||||
import { upsertKnownHost } from '../../domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
|
||||
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 {
|
||||
@@ -52,12 +55,13 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
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,
|
||||
@@ -98,6 +102,11 @@ type OpenSessionInNewWindowPayload = {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
type DeepLinkPayload = {
|
||||
url?: string;
|
||||
tabName?: string;
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
|
||||
@@ -139,14 +148,18 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
toggleWindowOpacity,
|
||||
} = 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 +215,7 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
updateHostLastConnected,
|
||||
updateHostDistro,
|
||||
importDataFromString,
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
@@ -248,30 +262,17 @@ export function AppSideEffects() {
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
logViews,
|
||||
closeLogView,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
getSessionRestoreCwd,
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
// AppSideEffects only needs host/session maps for glue handlers and effects.
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
},
|
||||
[runSnippet],
|
||||
);
|
||||
|
||||
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
|
||||
@@ -498,8 +499,8 @@ export function AppSideEffects() {
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
prepareApply: () =>
|
||||
prepareSyncPayloadApply(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
@@ -521,14 +522,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 }),
|
||||
@@ -626,6 +630,8 @@ export function AppSideEffects() {
|
||||
workspaces,
|
||||
});
|
||||
|
||||
useEffect(() => installEditorWindowSourceListeners(), []);
|
||||
|
||||
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
@@ -899,6 +905,8 @@ export function AppSideEffects() {
|
||||
// array thrash does not rebuild appTerminalDomain (see domain isolation).
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const canUseGlobalBroadcastRef = useRef(canUseGlobalBroadcast);
|
||||
canUseGlobalBroadcastRef.current = canUseGlobalBroadcast;
|
||||
// Logs live in connectionLogsStore — keep a ref fresh without re-rendering App.
|
||||
const connectionLogsRef = useRef(getConnectionLogsSnapshot().connectionLogs);
|
||||
useEffect(() => {
|
||||
@@ -1009,27 +1017,18 @@ export function AppSideEffects() {
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
// Used by the "Close all / Close others / Close to the left / Close to the
|
||||
// right" context-menu actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
const closingTabIds = new Set(targetIds);
|
||||
const activeBeforeClose = activeTabStore.getActiveTabId();
|
||||
const focusAfterClose = resolveBatchTabCloseFocus({
|
||||
orderedTabIds: orderedTabsWithEditors,
|
||||
closingTabIds,
|
||||
activeTabId: activeBeforeClose,
|
||||
});
|
||||
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
|
||||
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id));
|
||||
const canClose = !regularIds.length || await closeTabsBatchImpl(
|
||||
() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds: regularIds, workspaces }),
|
||||
regularIds,
|
||||
);
|
||||
if (!canClose) return;
|
||||
for (const id of pluginIds) pluginViewTabStore.close(id);
|
||||
if (closingTabIds.has(activeBeforeClose)) activeTabStore.setActiveTabId(focusAfterClose);
|
||||
},
|
||||
(targetIds: string[]) => closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
|
||||
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
|
||||
activeTabStore, editorTabStore, pluginViewTabStore,
|
||||
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
|
||||
}),
|
||||
targetIds,
|
||||
),
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView, orderedTabsWithEditors],
|
||||
);
|
||||
|
||||
@@ -1078,8 +1077,11 @@ export function AppSideEffects() {
|
||||
terminalPaneMagnificationRef,
|
||||
toEditorTabId,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
toggleWorkspaceViewMode,
|
||||
workspaces: workspacesRefForHotkeys.current,
|
||||
}), action, e);
|
||||
@@ -1092,6 +1094,8 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
moveFocusInWorkspace,
|
||||
toggleBroadcast,
|
||||
toggleWindowOpacity,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
confirmIfBusyLocalTerminal,
|
||||
]);
|
||||
@@ -1196,11 +1200,16 @@ export function AppSideEffects() {
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
|
||||
const handleConfirmDeleteHost = useCallback(async () => {
|
||||
if (!deleteHostConfirm) return;
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
try {
|
||||
await deleteVaultHosts([deleteHostConfirm.hostId]);
|
||||
setDeleteHostConfirm(null);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}, [deleteHostConfirm, deleteVaultHosts, t]);
|
||||
|
||||
const handleCancelDeleteHost = useCallback(() => {
|
||||
setDeleteHostConfirm(null);
|
||||
@@ -1352,8 +1361,32 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
// Local-shell vault hosts saved with "System default shell" carry no
|
||||
// localShell; resolve the Settings → Terminal shell here at the shared
|
||||
// effective-host boundary so every launch path (connect, tray/dock open,
|
||||
// workspace creation/append, tunnels) uses the configured shell instead
|
||||
// of silently falling back to the backend OS default shell.
|
||||
return resolveHostDefaultLocalShell(effectiveHost, {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
|
||||
},
|
||||
[runSnippet, resolveEffectiveHost],
|
||||
);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1384,14 +1417,25 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (sessionId && tabName) {
|
||||
renameSessionInline(sessionId, tabName);
|
||||
}
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
@@ -1410,6 +1454,10 @@ export function AppSideEffects() {
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
});
|
||||
|
||||
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||
if (!sessions.some((session) => session.id === sessionId)) {
|
||||
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
||||
@@ -1452,6 +1500,7 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
@@ -1460,15 +1509,16 @@ export function AppSideEffects() {
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
@@ -1497,25 +1547,26 @@ export function AppSideEffects() {
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
const hostDraft = buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
});
|
||||
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
@@ -1532,9 +1583,10 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
@@ -1555,10 +1607,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1567,10 +1619,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
@@ -1751,6 +1803,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 +1946,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
runSnippet: handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
toggleScriptsSidePanelRef,
|
||||
@@ -1962,6 +2023,7 @@ export function AppSideEffects() {
|
||||
handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal,
|
||||
updateTerminalHosts,
|
||||
updateTerminalHostDistro,
|
||||
handleRunSnippet,
|
||||
splitSessionWithCurrentShell,
|
||||
handleEndSessionDrag,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
@@ -36,6 +37,7 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
@@ -259,7 +261,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
@@ -300,6 +302,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
localShellSidePanelAutoOpen,
|
||||
localShellSidePanelAutoOpenTab,
|
||||
} = useSettingsChromeStore();
|
||||
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||
|
||||
@@ -351,14 +355,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
defaultLocalShell,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
@@ -418,6 +424,23 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
if (tab.placement === "window") {
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
|
||||
const ok = Boolean(result?.success) && result?.cancelled !== true;
|
||||
if (ok) {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() === closingTabId) {
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
@@ -429,7 +452,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
@@ -460,6 +483,16 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handlePopOutEditorTab = useCallback(async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab || tab.placement === "window") {
|
||||
if (tab) void focusEditorInWindow(id);
|
||||
return;
|
||||
}
|
||||
const host = hostById.get(tab.hostId);
|
||||
await popOutEditorTab(id, host?.label);
|
||||
}, [hostById]);
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
@@ -504,6 +537,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
@@ -535,6 +569,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
onPopOutEditorTab={handlePopOutEditorTab}
|
||||
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
@@ -713,6 +749,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onClearCommandHistory={clearAllCommandHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -751,6 +788,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
|
||||
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
@@ -783,7 +822,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
))}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
@@ -792,6 +831,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TransferOwnerObservation } from "../state/sftp/waitForTransferOwner";
|
||||
import type { TransferStatus, TransferTask } from "../../domain/models";
|
||||
|
||||
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
|
||||
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
|
||||
|
||||
export interface DedicatedResumeChildUpdateBatcher {
|
||||
push(task: TransferTask): void;
|
||||
push(task: TransferTask, observation?: TransferOwnerObservation): void;
|
||||
discard(taskId: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
@@ -24,25 +26,39 @@ export function createDedicatedResumeChildUpdateBatcher(deps: {
|
||||
hasTask: (taskId: string) => boolean;
|
||||
upsertTasks: (tasks: readonly TransferTask[]) => void;
|
||||
}): DedicatedResumeChildUpdateBatcher {
|
||||
const pending = new Map<string, TransferTask>();
|
||||
const pending = new Map<string, { task: TransferTask; observation?: TransferOwnerObservation }>();
|
||||
const flush = () => {
|
||||
if (pending.size === 0) return;
|
||||
const batch = [...pending.values()];
|
||||
pending.clear();
|
||||
deps.upsertTasks(batch);
|
||||
try {
|
||||
const updates = batch.filter(({ observation }) => !observation?.hasIdentityConflict()).map(({ task }) => task);
|
||||
if (updates.length) deps.upsertTasks(updates);
|
||||
} finally {
|
||||
for (const { observation } of batch) observation?.dispose();
|
||||
}
|
||||
};
|
||||
return {
|
||||
push(task) {
|
||||
push(task, observation) {
|
||||
if (observation?.hasIdentityConflict()) {
|
||||
pending.get(task.id)?.observation?.dispose();
|
||||
pending.delete(task.id);
|
||||
observation.dispose();
|
||||
return;
|
||||
}
|
||||
const shouldBatch = !!task.parentTaskId
|
||||
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
|
||||
&& deps.hasTask(task.id);
|
||||
if (!shouldBatch) {
|
||||
deps.upsertTasks([task]);
|
||||
try { deps.upsertTasks([task]); } finally { observation?.dispose(); }
|
||||
return;
|
||||
}
|
||||
pending.set(task.id, task);
|
||||
const previous = pending.get(task.id);
|
||||
if (previous?.observation && previous.observation !== observation) previous.observation.dispose();
|
||||
pending.set(task.id, { task, observation });
|
||||
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
|
||||
},
|
||||
discard: (taskId) => { pending.get(taskId)?.observation?.dispose(); pending.delete(taskId); },
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
@@ -37,7 +36,6 @@ export function TerminalHost() {
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const vaultActions = useVaultSnapshotActions();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const terminalSettingsActions = useTerminalSettingsActions();
|
||||
const {
|
||||
@@ -247,7 +245,7 @@ export function TerminalHost() {
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: vaultActions?.updateHostDistro,
|
||||
updateHostDistro: handlers.updateTerminalHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||
@@ -275,7 +273,6 @@ export function TerminalHost() {
|
||||
terminalHosts,
|
||||
terminalSettings,
|
||||
terminalSettingsActions,
|
||||
vaultActions,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -36,6 +36,7 @@ export function VaultHost() {
|
||||
return {
|
||||
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
clearAllCommandHistory: actions?.clearAllCommandHistory,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
|
||||
@@ -72,6 +72,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
} = vault;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -144,12 +145,14 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
});
|
||||
return () => {
|
||||
registerVaultSnapshotActions(null);
|
||||
};
|
||||
}, [
|
||||
addShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
|
||||
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
|
||||
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { editorTabStore } from "../state/editorTabStore.ts";
|
||||
import { popOutEditorTab } from "../state/editorWindowClient.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
|
||||
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const editorId = "quit-during-editor-handoff";
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
editorTabStore.close(editorId);
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
let query!: () => Promise<void>;
|
||||
let accept!: (result: { success: boolean }) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(toast, "warning", () => {});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
openEditorWindow: () => receipt,
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
|
||||
}));
|
||||
function SourceWindow() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
|
||||
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
|
||||
const opening = popOutEditorTab(editorId);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
|
||||
accept({ success: true });
|
||||
assert.equal(await opening, true);
|
||||
assert.equal(editorTabStore.getTab(editorId)?.content, "");
|
||||
await query();
|
||||
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
|
||||
});
|
||||
@@ -181,11 +181,14 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
},
|
||||
{
|
||||
children,
|
||||
onChildUpdate: (child) => {
|
||||
onChildUpdate: (child, observation) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
|
||||
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" }, observation);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onChildSuperseded: (taskId) => childUpdateBatcher.discard(taskId),
|
||||
flushChildUpdates: () => childUpdateBatcher.flush(),
|
||||
onDirectoryCheckpointUpdate: (checkpoint) => {
|
||||
if (acceptsResumeCallbacks) {
|
||||
sftpTransferCenterStore.patchTask(task.id, {
|
||||
@@ -354,6 +357,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (!hasDirty) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ConvergentMigrationProviderInput,
|
||||
} from '../domain/convergentSync';
|
||||
import { isProviderReadyForSync } from '../domain/sync';
|
||||
import { isPreserveDeletionsStrategy } from '../domain/syncStrategy';
|
||||
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
|
||||
import { applyProtectedSyncPayload } from './localVaultBackups';
|
||||
@@ -113,6 +114,7 @@ export async function prepareConvergentSyncMigration(
|
||||
providers: inputs,
|
||||
deviceId: manager.getState().deviceId,
|
||||
now,
|
||||
preserveDeletions: isPreserveDeletionsStrategy(manager.getState().syncStrategy),
|
||||
});
|
||||
if (plan.state) {
|
||||
for (const seed of legacyBaselineSeeds) {
|
||||
@@ -137,7 +139,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 +166,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 +198,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();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { startPortForward } from '../infrastructure/services/portForwardingService';
|
||||
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
|
||||
interface OpenHostBrowserOptions {
|
||||
host: Host;
|
||||
sessionId?: string;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
}
|
||||
|
||||
const pendingOpens = new Map<string, Promise<void>>();
|
||||
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
|
||||
|
||||
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
|
||||
const value = style.getPropertyValue(`--${token}`).trim();
|
||||
return isValidHslToken(value) ? [[token, value]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
|
||||
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
|
||||
}
|
||||
|
||||
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
|
||||
await bridge.openHostBrowserUrl({ browserId, url });
|
||||
}
|
||||
|
||||
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
|
||||
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
|
||||
const existing = pendingOpens.get(options.browserId);
|
||||
if (existing) return existing;
|
||||
const opening = (async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
|
||||
throw new Error('The host browser is unavailable in this build');
|
||||
}
|
||||
const local = host.protocol === 'local';
|
||||
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
|
||||
id: `host-browser-${crypto.randomUUID()}`,
|
||||
label: `Browser: ${host.label || host.hostname}`,
|
||||
type: 'dynamic',
|
||||
hostId: host.id,
|
||||
localPort: 0,
|
||||
bindAddress: '127.0.0.1',
|
||||
status: 'inactive',
|
||||
createdAt: Date.now(),
|
||||
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
|
||||
knownHosts, { dedicatedTransport: true, prepareOnly: true });
|
||||
if (!result.success || (!local && !result.connectionOptions)) {
|
||||
throw new Error(result.error || 'Could not prepare the browser connection');
|
||||
}
|
||||
await bridge.openHostBrowser({
|
||||
browserId: options.browserId,
|
||||
embedded: true,
|
||||
local,
|
||||
hostId: host.id,
|
||||
label: host.label || host.hostname,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
themeTokens: currentBrowserThemeTokens(),
|
||||
connectionOptions: result.connectionOptions,
|
||||
});
|
||||
})();
|
||||
pendingOpens.set(options.browserId, opening);
|
||||
void opening.finally(() => {
|
||||
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
|
||||
}).catch(() => {});
|
||||
return opening;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
|
||||
let cachedBookmarks: BrowserBookmark[] | undefined;
|
||||
|
||||
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
|
||||
return cachedBookmarks?.map((item) => ({ ...item }));
|
||||
}
|
||||
|
||||
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
|
||||
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
|
||||
if (!reader) return cachedBookmarks;
|
||||
const items = await reader();
|
||||
cachedBookmarks = items;
|
||||
return getCachedHostBrowserBookmarks();
|
||||
}
|
||||
|
||||
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
|
||||
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
|
||||
if (!writer) return;
|
||||
await writer(items);
|
||||
cachedBookmarks = items;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
const keys = [
|
||||
'cloudSync.provider.disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle',
|
||||
'cloudSync.provider.disconnect.confirmMessage',
|
||||
'cloudSync.provider.disconnect.confirmAction',
|
||||
'cloudSync.provider.disconnect.toastTitle',
|
||||
'cloudSync.provider.disconnect.cloudRemains',
|
||||
'cloudSync.provider.disconnect.failed',
|
||||
] as const;
|
||||
|
||||
test('disconnect copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect confirm copy states that saved provider credentials are removed', () => {
|
||||
// The confirm dialog must explain the data consequence of disconnecting:
|
||||
// locally saved endpoint + credentials removal, not just "stops syncing".
|
||||
const expected = [
|
||||
[en, 'will be removed'],
|
||||
[es, 'eliminarán'],
|
||||
[ru, 'будут удалены'],
|
||||
[zhCN, '删除'],
|
||||
[zhTW, '刪除'],
|
||||
] as const;
|
||||
for (const [messages, fragment] of expected) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
|
||||
`confirmMessage should mention credential removal: ${fragment}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect toast copy explains the cloud copy is retained', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
|
||||
'{name}',
|
||||
),
|
||||
`${locale} cloudRemains hint should reference the provider name`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect copy distinguishes login cleanup from retained local data', () => {
|
||||
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
|
||||
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
|
||||
for (const messages of [en, ru, es, zhCN, zhTW]) {
|
||||
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
|
||||
}
|
||||
});
|
||||
@@ -11,10 +11,16 @@ const strategyKeys = [
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.smartMergePreserve",
|
||||
"cloudSync.strategy.smartMergePreserveDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
"cloudSync.aiSessions.title",
|
||||
"cloudSync.aiSessions.desc",
|
||||
"cloudSync.aiSessions.compatibility",
|
||||
"cloudSync.aiSessions.enableFailed",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.modelCatalogRetry': 'Retry',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Context compacted',
|
||||
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent activity',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -53,6 +57,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 +238,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Reasoning Effort',
|
||||
'ai.codebuddy.effort.default': 'Default',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
|
||||
'ai.codebuddy.maxTurns': 'Max Turns',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
|
||||
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
|
||||
'ai.codebuddy.sandbox': 'Sandbox Mode',
|
||||
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
|
||||
'ai.codebuddy.persistSession': 'Session Persistence',
|
||||
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
|
||||
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
|
||||
@@ -273,6 +290,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': "Xiaomi's MiMo Code CLI, an OpenCode fork. Install it and configure providers in its own config, then select it as an external coding agent.",
|
||||
'ai.mimo.detecting': 'Detecting...',
|
||||
'ai.mimo.detected': 'Detected',
|
||||
'ai.mimo.notFound': 'Not found',
|
||||
'ai.mimo.path': 'Path:',
|
||||
'ai.mimo.notFoundHint': 'Could not find mimo in PATH. Install it or specify the executable path below.',
|
||||
'ai.mimo.customPathPlaceholder': 'e.g. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Check',
|
||||
'ai.mimo.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
@@ -309,6 +338,13 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Always',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
|
||||
'ai.externalMcp.allowAll': 'Allow all actions',
|
||||
'ai.externalMcp.allowSession': 'This connection',
|
||||
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
|
||||
'ai.externalMcp.allowHost': 'This server',
|
||||
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
@@ -350,21 +386,24 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Other',
|
||||
'ai.externalMcp.cliCommand': 'CLI command',
|
||||
'ai.externalMcp.configSnippet': 'Config snippet',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Universal setup prompt',
|
||||
'ai.externalMcp.addToCodex': 'Add to Codex',
|
||||
'ai.externalMcp.addToClaude': 'Add to Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Add to Grok',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.codexAdded': 'Codex MCP entry and Netcatty skill added. Restart Codex or open a new Codex session.',
|
||||
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry and Netcatty skill added. Restart Claude Code or open a new Claude Code session.',
|
||||
'ai.externalMcp.grokAdded': 'Grok MCP entry and Netcatty skill added. Restart Grok or open a new Grok session.',
|
||||
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
|
||||
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
|
||||
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
|
||||
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
|
||||
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / other clients',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Merge this into your Cursor MCP configuration without replacing existing servers.',
|
||||
'ai.externalMcp.other.description': 'Copy the setup prompt into another terminal agent, or merge the MCP snippet into its client configuration.',
|
||||
'ai.externalMcp.status.unavailable': 'Unavailable',
|
||||
'ai.externalMcp.status.disabled': 'Disabled',
|
||||
'ai.externalMcp.status.running': 'Running',
|
||||
@@ -471,9 +510,14 @@ 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...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Live catalog',
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
@@ -483,13 +527,21 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.undoLastTurn': 'Undo last turn',
|
||||
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.searchSessions': 'Search titles and recent content…',
|
||||
'ai.chat.clearSessionSearch': 'Clear session search',
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.forkFromHere': 'Fork from here',
|
||||
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
@@ -497,6 +549,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
@@ -575,8 +634,16 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.movePanelBottom': 'Move panel to bottom',
|
||||
'terminal.layer.choosePanelPosition': 'Change position',
|
||||
'terminal.layer.dockLeft': 'Left',
|
||||
'terminal.layer.dockRight': 'Right',
|
||||
'terminal.layer.dockBottom': 'Bottom',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
|
||||
'terminal.layer.layoutSaveFailed': 'Could not save layout',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
|
||||
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'sort.ip': 'By IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
@@ -77,7 +78,7 @@ export const enCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'placeholder.searchHosts': 'Search hosts or tags...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
@@ -311,8 +312,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
@@ -537,7 +541,7 @@ Highlight the focused split pane:
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Bright',
|
||||
'settings.appearance.appIcon.dark': 'Dark',
|
||||
@@ -613,6 +617,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.ligatures': 'Font ligatures',
|
||||
'settings.terminal.font.ligatures.desc': 'Render ligatures such as != when the terminal font includes them',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -620,6 +626,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.cursor.style.block': 'Block',
|
||||
'settings.terminal.cursor.style.bar': 'Bar',
|
||||
'settings.terminal.cursor.style.underline': 'Underline',
|
||||
'settings.terminal.cursor.barWidth': 'Bar cursor width',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Width of bar cursors used by applications such as vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Cursor blink',
|
||||
'settings.terminal.cursor.highlightLine': 'Highlight current line',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -645,10 +653,18 @@ Highlight the focused split pane:
|
||||
'settings.terminal.sidePanel.pane.system': 'System',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notes',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': 'Local Shell Side Panel',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Open side panel with local shell',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Automatically open a side panel when opening a local shell.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Default side panel',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Files',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Right-click hold opens menu',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'When right-click pastes or selects a word, hold for half a second to open the menu. Does not override terminal apps that capture the mouse.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
|
||||
@@ -685,12 +701,21 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Ask before pasting multiple lines',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Show a confirmation dialog before pasting multiple lines, with the line/character count, an editable preview, and Send / Send line by line / Cancel actions. Protects switch and firewall CLIs (Cisco IOS, Huawei VRP, H3C Comware) that do not support bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Confirmation threshold (lines)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Ask for confirmation when a paste contains at least this many lines.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
@@ -706,6 +731,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.',
|
||||
@@ -737,6 +767,11 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Browser',
|
||||
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
@@ -1037,6 +1072,10 @@ Highlight the focused split pane:
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'This group contains hosts imported from a linked SSH config file. Every host under its managed subgroup will be deleted, even hosts not managed by that file. All other hosts will move to the root level unless you also delete them below.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'Deleting this group will also clear its entries from the linked SSH config file. Entries that came from that file may be lost for other apps that use it. Back up the file before continuing.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'The managed files affected by this deletion have changed. Review the files and confirm again.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Linked file: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Delete Selected Groups',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'This will permanently delete the selected groups and move their hosts to the root level.',
|
||||
@@ -1089,12 +1128,14 @@ Highlight the focused split pane:
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '{count} saved key passphrases could not be read and were left blank',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '{count} saved proxy passwords could not be read and were left blank',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
@@ -1113,6 +1154,9 @@ Highlight the focused split pane:
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const enScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
'scripts.actions.runOnAllTabs': 'Run on all tabs',
|
||||
'scripts.actions.copyCommand': 'Copy command',
|
||||
'scripts.actions.copyCommand.copied': 'Command copied',
|
||||
'scripts.actions.copyCommand.failed': 'Could not copy command',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
'terminal.pasteConfirm.title': 'Paste multiple lines?',
|
||||
'terminal.pasteConfirm.summary': '{lines} lines ({chars} characters) will be sent to the session.',
|
||||
'terminal.pasteConfirm.preview': 'Preview (editable)',
|
||||
'terminal.pasteConfirm.send': 'Send',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Send line by line',
|
||||
'terminal.pasteConfirm.cancel': 'Cancel',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
@@ -11,6 +20,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.browser.title': 'Browser',
|
||||
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
|
||||
'terminal.browser.open': 'Open Browser',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
|
||||
@@ -35,6 +47,10 @@ export const enTerminalMessages: Messages = {
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'history.action.delete': 'Delete from history',
|
||||
'history.action.clearAll': 'Clear all history',
|
||||
'history.clearAll.dialog.title': 'Clear all command history?',
|
||||
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
|
||||
'history.clearAll.dialog.confirm': 'Clear all',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
@@ -52,6 +68,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
@@ -91,6 +108,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
@@ -124,6 +142,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.cut': 'Cut',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
@@ -178,6 +197,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
@@ -186,6 +206,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Waiting for user input',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.connected': 'Connected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
@@ -251,6 +272,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',
|
||||
@@ -295,8 +317,11 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Connecting...',
|
||||
'cloudSync.provider.disconnect': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
|
||||
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'S3 Compatible',
|
||||
@@ -366,6 +391,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Sync strategy',
|
||||
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
|
||||
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
|
||||
'cloudSync.aiSessions.title': 'Sync AI chat history',
|
||||
'cloudSync.aiSessions.desc': 'Include AI chat sessions in cloud sync. Messages merge append-only across devices; attachment contents stay on this device.',
|
||||
'cloudSync.aiSessions.compatibility': 'Older versions may overwrite chat history. Use an updated Netcatty on every synced device.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Could not update chat sync. Check the cloud connection and available local storage, then try again.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Smart merge (never delete)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combine changes from both sides and never propagate a one-sided deletion — an entry survives until it is removed on every device. Recommended when you restore backups or keep several machines in sync.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
|
||||
'cloudSync.strategy.preferCloud': 'Cloud wins',
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
@@ -503,6 +534,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
|
||||
'cloudSync.conflict.entity.settings': 'Settings',
|
||||
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
|
||||
@@ -637,6 +669,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 +840,9 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.backspaceBehavior': 'Backspace key',
|
||||
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
|
||||
'serial.encoding.pendingInput': 'Submit or clear the current input before changing the encoding.',
|
||||
'serial.field.byteOrientedBackspace': 'Byte-Oriented Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Send one DEL per byte so the device deletes a whole multi-byte character (for devices that delete bytes). Disable for character-aware line editors (e.g. Linux readline).',
|
||||
'serial.backspace.default': 'Default (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Charset',
|
||||
@@ -820,6 +856,10 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
'serial.field.username': 'Username',
|
||||
'serial.field.password': 'Password',
|
||||
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',
|
||||
'serial.field.clearPassword': 'Clear saved password',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
@@ -846,6 +886,13 @@ export const enTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.popOut': 'Open in editor window',
|
||||
'sftp.editor.dock': 'Dock to main window',
|
||||
'sftp.editor.focusWindow': 'Focus editor window',
|
||||
'sftp.editor.close': 'Close',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No files open',
|
||||
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
|
||||
@@ -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',
|
||||
@@ -400,6 +407,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transfer next',
|
||||
'sftp.transferCenter.pauseAll': 'Pause all',
|
||||
'sftp.transferCenter.resumeAll': 'Resume all',
|
||||
'sftp.transferCenter.cancelAll': 'Cancel all',
|
||||
'sftp.transferCenter.empty': 'No transfers in this category',
|
||||
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
|
||||
'sftp.transferCenter.hideBackground': 'Hide background transfers',
|
||||
@@ -707,11 +715,26 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'New Host',
|
||||
'vault.panel.resizeWidth': 'Resize panel width',
|
||||
'hostDetails.saveAria': 'Save',
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Test Connection',
|
||||
'hostConnectionTest.test': 'Test',
|
||||
'hostConnectionTest.chainOf': 'Chain {current} of {total}',
|
||||
'hostConnectionTest.success': 'Connection successful',
|
||||
'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.',
|
||||
'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s',
|
||||
'hostConnectionTest.failed': 'Connection failed.',
|
||||
'hostConnectionTest.retry': 'Retry',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.',
|
||||
'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',
|
||||
@@ -995,6 +1018,21 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
// Local shell (CMD) host entries
|
||||
'localShell.subtitle': 'Local Shell',
|
||||
'localShell.panel.title': 'New Local Shell',
|
||||
'localShell.panel.title.edit': 'Edit Local Shell',
|
||||
'localShell.field.label': 'Name',
|
||||
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'System default shell',
|
||||
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No shells found',
|
||||
'localShell.field.shellResolved': 'Runs {command}',
|
||||
'localShell.field.startDir': 'Start directory',
|
||||
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
|
||||
'localShell.saveAndConnect': 'Save & Connect',
|
||||
|
||||
'hostDetails.plugin.title': 'Plugin connection',
|
||||
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
|
||||
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.modelCatalogRetry': 'Reintentar',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': 'Contexto compactado',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
@@ -225,20 +229,24 @@ export const esAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.minimal': 'Mínimo',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.effort.max': 'Máximo',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.persistSession': 'Persistencia de sesión',
|
||||
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
@@ -273,6 +281,18 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': 'CLI de MiMo Code de Xiaomi, un fork de OpenCode. Instálalo y configura los proveedores en su propia configuración, luego selecciónalo como agente de codificación externo.',
|
||||
'ai.mimo.detecting': 'Detectando...',
|
||||
'ai.mimo.detected': 'Detectado',
|
||||
'ai.mimo.notFound': 'No encontrado',
|
||||
'ai.mimo.path': 'Ruta:',
|
||||
'ai.mimo.notFoundHint': 'No se pudo encontrar mimo en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.mimo.customPathPlaceholder': 'p. ej. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Verificar',
|
||||
'ai.mimo.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
@@ -309,6 +329,13 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Siempre',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
|
||||
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
|
||||
'ai.externalMcp.allowSession': 'Esta conexión',
|
||||
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
|
||||
'ai.externalMcp.allowHost': 'Este servidor',
|
||||
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
@@ -350,21 +377,24 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Otros',
|
||||
'ai.externalMcp.cliCommand': 'Comando CLI',
|
||||
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Prompt de instalación universal',
|
||||
'ai.externalMcp.addToCodex': 'Agregar a Codex',
|
||||
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Agregar a Grok',
|
||||
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.codexAdded': 'Entrada MCP de Codex y skill de Netcatty agregados. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada MCP de Claude Code y skill de Netcatty agregados. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada MCP de Grok y skill de Netcatty agregados. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
|
||||
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto con la configuración MCP de Cursor sin reemplazar los servidores existentes.',
|
||||
'ai.externalMcp.other.description': 'Copia el prompt de instalación en otro agente de terminal o combina el fragmento MCP con la configuración de su cliente.',
|
||||
'ai.externalMcp.status.unavailable': 'No disponible',
|
||||
'ai.externalMcp.status.disabled': 'Desactivado',
|
||||
'ai.externalMcp.status.running': 'En ejecución',
|
||||
@@ -471,9 +501,14 @@ 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...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Catálogo en vivo',
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
@@ -483,13 +518,21 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.undoLastTurn': 'Deshacer el último turno',
|
||||
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
'ai.chat.jumpUntitled': '(mensaje vacío)',
|
||||
'ai.chat.usedTools': 'Herramientas usadas: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
|
||||
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
|
||||
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
|
||||
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
@@ -497,6 +540,13 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
@@ -575,6 +625,11 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
|
||||
'terminal.layer.choosePanelPosition': 'Cambiar posición',
|
||||
'terminal.layer.dockLeft': 'Izquierda',
|
||||
'terminal.layer.dockRight': 'Derecha',
|
||||
'terminal.layer.dockBottom': 'Abajo',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Más reciente a más antiguo',
|
||||
'sort.oldest': 'Más antiguo a más reciente',
|
||||
'sort.ip': 'Por IP',
|
||||
'sort.group': 'Por grupo',
|
||||
'field.label': 'Etiqueta',
|
||||
'field.type': 'Tipo',
|
||||
@@ -77,7 +78,7 @@ export const esCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Seleccionar hosts',
|
||||
'placeholder.workspaceName': 'Nombre del espacio de trabajo',
|
||||
'placeholder.sessionName': 'Nombre de la sesión',
|
||||
'placeholder.searchHosts': 'Buscar hosts...',
|
||||
'placeholder.searchHosts': 'Buscar hosts o etiquetas...',
|
||||
'toast.settingsUnavailable': 'La ventana de configuración no está disponible en esta plataforma.',
|
||||
'credentials.protectionUnavailable.title': 'Protección de credenciales no disponible',
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
@@ -226,8 +227,11 @@ export const esCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
|
||||
'settings.globalHotkey.notSet': 'No configurada',
|
||||
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
|
||||
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
|
||||
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
|
||||
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
|
||||
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
|
||||
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
|
||||
@@ -452,7 +456,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Brillante',
|
||||
'settings.appearance.appIcon.dark': 'Oscuro',
|
||||
@@ -528,6 +532,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.font.weightBold.desc': 'Peso del texto en negrita (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Suavizado de fuente',
|
||||
'settings.terminal.font.smoothing.desc': 'Usar el anti-aliasing nativo de fuentes de macOS',
|
||||
'settings.terminal.font.ligatures': 'Ligaduras de fuente',
|
||||
'settings.terminal.font.ligatures.desc': 'Mostrar ligaduras como != cuando la fuente del terminal las incluye',
|
||||
'settings.terminal.font.linePadding': 'Espaciado de líneas',
|
||||
'settings.terminal.font.linePadding.desc': 'Espacio adicional entre líneas (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Tipo de emulación de terminal',
|
||||
@@ -535,6 +541,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.cursor.style.block': 'Bloque',
|
||||
'settings.terminal.cursor.style.bar': 'Barra',
|
||||
'settings.terminal.cursor.style.underline': 'Subrayado',
|
||||
'settings.terminal.cursor.barWidth': 'Ancho del cursor de barra',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ancho de los cursores de barra utilizados por aplicaciones como vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Parpadeo del cursor',
|
||||
'settings.terminal.cursor.highlightLine': 'Resaltar línea actual',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -560,10 +568,18 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.sidePanel.pane.system': 'Sistema',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notas',
|
||||
'settings.terminal.sidePanel.pane.ai': 'IA',
|
||||
'settings.terminal.section.localShellSidePanel': 'Panel lateral de Shell local',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Expandir panel lateral al abrir Shell local',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Abre automáticamente un panel lateral después de abrir el Shell local.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Panel lateral predeterminado',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Elige qué panel se abre primero.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Archivos',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
|
||||
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Acción al hacer clic derecho en la terminal',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Mantener clic derecho abre el menú',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Si el clic derecho pega o selecciona una palabra, mantén pulsado medio segundo para abrir el menú. No afecta a las aplicaciones que capturan el ratón.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Mostrar menú sobre aplicaciones en pantalla completa',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Muestra el menú contextual incluso cuando tmux, vim u otras aplicaciones en pantalla completa toman el control del mouse. Cuando está desactivado, el clic derecho se pasa a la aplicación (Shift+Clic derecho sigue abriendo el menú).',
|
||||
@@ -600,12 +616,21 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Subir automáticamente imágenes del portapapeles al pegar',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Cuando el portapapeles contiene una imagen, pegarla en una sesión remota la sube por SFTP a .netcatty-paste-images/ dentro del directorio de trabajo remoto e inserta la ruta remota en lugar de pegar texto.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Preguntar antes de pegar varias líneas',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Muestra un diálogo de confirmación antes de pegar varias líneas, con el número de líneas y caracteres, una vista previa editable y las acciones Enviar / Enviar línea por línea / Cancelar. Protege las CLI de dispositivos de red (Cisco IOS, Huawei VRP, H3C Comware) que no admiten bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Umbral de confirmación (líneas)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Pedir confirmación cuando el texto pegado contenga al menos esta cantidad de líneas.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Texto de Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` borra el historial de desplazamiento',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Haz que `clear` también borre el buffer del historial de desplazamiento (predeterminado de POSIX). Desactívalo para mantener visible el historial después de `clear`.',
|
||||
@@ -621,6 +646,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.',
|
||||
@@ -652,6 +682,11 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Navegador',
|
||||
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
|
||||
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
|
||||
@@ -853,6 +888,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.new-workspace': 'Nuevo espacio de trabajo',
|
||||
'settings.shortcuts.binding.snippets': 'Abrir fragmentos',
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
@@ -995,6 +1031,10 @@ Resalta el panel dividido enfocado:
|
||||
'vault.groups.deleteDialogTitle': 'Eliminar grupo',
|
||||
'vault.groups.deleteDialog.desc': 'Esto eliminará permanentemente el grupo y moverá todos los hosts al nivel raíz.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'Este es un grupo administrado de configuración SSH. Eliminarlo también eliminará todos los hosts y lo desvinculará del archivo de origen.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'Este grupo contiene hosts importados de un archivo de configuración SSH vinculado. Todos los hosts bajo su subgrupo administrado se eliminarán, incluso los hosts no administrados por ese archivo. Los demás hosts se moverán al nivel raíz a menos que también los elimine abajo.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'Al eliminar este grupo también se borrarán sus entradas del archivo de configuración SSH vinculado. Las entradas provenientes de ese archivo pueden perderse y también afectará a otras aplicaciones que lo usen. Haz una copia de seguridad del archivo antes de continuar.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'Los archivos administrados afectados por esta eliminación han cambiado. Revisa los archivos y vuelve a confirmar.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Archivo vinculado: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'También eliminar todos los hosts de este grupo',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Eliminar grupos seleccionados',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'Esto eliminará permanentemente los grupos seleccionados y moverá sus hosts al nivel raíz.',
|
||||
@@ -1047,12 +1087,14 @@ Resalta el panel dividido enfocado:
|
||||
'vault.tree.expandAll': 'Expandir todo',
|
||||
'vault.tree.collapseAll': 'Contraer todo',
|
||||
'vault.hosts.newHost': 'Nuevo host',
|
||||
'vault.hosts.newLocalShell': 'Shell local (CMD)',
|
||||
'vault.hosts.newGroup': 'Nuevo grupo',
|
||||
'vault.hosts.import': 'Importar',
|
||||
'vault.hosts.export': 'Exportar',
|
||||
'vault.hosts.export.toast.success': 'Se exportaron {count} hosts a CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Se exportaron {count} hosts a CSV (se omitieron {skipped} hosts no compatibles)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'No se pudieron leer {count} frases de contraseña de claves guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'No se pudieron leer {count} contraseñas de proxy guardadas y se dejaron en blanco',
|
||||
'vault.hosts.export.toast.noHosts': 'No hay hosts para exportar',
|
||||
'vault.hosts.allHosts': 'Todos los hosts',
|
||||
'vault.hosts.pinned': 'Fijados',
|
||||
@@ -1071,6 +1113,9 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Conectar ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const esScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.copyCommand': 'Copiar comando',
|
||||
'scripts.actions.copyCommand.copied': 'Comando copiado',
|
||||
'scripts.actions.copyCommand.failed': 'No se pudo copiar el comando',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
'terminal.pasteConfirm.title': '¿Pegar varias líneas?',
|
||||
'terminal.pasteConfirm.summary': 'Se enviarán {lines} líneas ({chars} caracteres) a la sesión.',
|
||||
'terminal.pasteConfirm.preview': 'Vista previa (editable)',
|
||||
'terminal.pasteConfirm.send': 'Enviar',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Enviar línea por línea',
|
||||
'terminal.pasteConfirm.cancel': 'Cancelar',
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
@@ -11,6 +20,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.browser.title': 'Navegador',
|
||||
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
|
||||
'terminal.browser.open': 'Abrir navegador',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
@@ -35,6 +47,10 @@ export const esTerminalMessages: Messages = {
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'history.action.clearAll': 'Borrar todo el historial',
|
||||
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
|
||||
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
|
||||
'history.clearAll.dialog.confirm': 'Borrar todo',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
@@ -52,6 +68,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
@@ -91,6 +108,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.gpu': 'Uso de GPU',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
@@ -124,6 +142,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.cut': 'Cortar',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
@@ -178,6 +197,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
@@ -251,6 +271,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',
|
||||
@@ -295,8 +316,11 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.disconnect': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
|
||||
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
@@ -366,6 +390,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.aiSessions.title': 'Sincronizar el historial de chats de IA',
|
||||
'cloudSync.aiSessions.desc': 'Incluye las sesiones de chat de IA en la sincronización en la nube. Los mensajes se combinan de forma incremental entre dispositivos; el contenido de los adjuntos permanece en este dispositivo.',
|
||||
'cloudSync.aiSessions.compatibility': 'Las versiones antiguas pueden sobrescribir el historial. Actualiza Netcatty en todos los dispositivos sincronizados.',
|
||||
'cloudSync.aiSessions.enableFailed': 'No se pudo actualizar la sincronización del chat. Comprueba la conexión con la nube y el espacio local disponible e inténtalo de nuevo.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Fusión inteligente (sin borrar)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combina los cambios de ambos lados y nunca propaga un borrado de un solo lado: una entrada se conserva hasta que se elimina en todos los dispositivos. Recomendado al restaurar copias de seguridad o al usar varios equipos.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
@@ -503,6 +533,7 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
@@ -637,6 +668,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 +839,9 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.encoding.pendingInput': 'Envía o borra la entrada actual antes de cambiar la codificación.',
|
||||
'serial.field.byteOrientedBackspace': 'Retroceso orientado a bytes',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Envía un DEL por byte para que el dispositivo elimine un carácter multibyte completo (para dispositivos que eliminan por bytes). Desactívelo para editores de línea conscientes de caracteres (p. ej. readline de Linux).',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
@@ -820,6 +855,10 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
|
||||
'serial.connectAndSave': 'Conectar y guardar',
|
||||
'serial.edit.title': 'Configuración del puerto serial',
|
||||
'serial.field.username': 'Usuario',
|
||||
'serial.field.password': 'Contraseña',
|
||||
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',
|
||||
'serial.field.clearPassword': 'Borrar la contraseña guardada',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Se requiere autenticación',
|
||||
@@ -846,6 +885,13 @@ export const esTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.popOut': 'Abrir en ventana de editor',
|
||||
'sftp.editor.dock': 'Anclar a la ventana principal',
|
||||
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
|
||||
'sftp.editor.close': 'Cerrar',
|
||||
'sftp.editor.windowTitle': 'Editor',
|
||||
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
|
||||
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
|
||||
@@ -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',
|
||||
@@ -400,6 +407,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transferir a continuación',
|
||||
'sftp.transferCenter.pauseAll': 'Pausar todas',
|
||||
'sftp.transferCenter.resumeAll': 'Reanudar todas',
|
||||
'sftp.transferCenter.cancelAll': 'Cancelar todas',
|
||||
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
|
||||
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
|
||||
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
|
||||
@@ -710,8 +718,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',
|
||||
@@ -995,6 +1008,21 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
'localShell.panel.title.edit': 'Editar shell local',
|
||||
'localShell.field.label': 'Nombre',
|
||||
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
|
||||
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No se encontraron shells',
|
||||
'localShell.field.shellResolved': 'Ejecuta {command}',
|
||||
'localShell.field.startDir': 'Directorio de inicio',
|
||||
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
|
||||
'localShell.saveAndConnect': 'Guardar y conectar',
|
||||
|
||||
'hostDetails.plugin.title': 'Conexión de plugin',
|
||||
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
|
||||
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.modelCatalogRetry': 'Повторить',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
'ai.providers': 'Провайдеры',
|
||||
@@ -206,6 +210,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': 'Выберите вариант',
|
||||
@@ -261,6 +285,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Всегда',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
|
||||
'ai.externalMcp.allowAll': 'Разрешить все действия',
|
||||
'ai.externalMcp.allowSession': 'Это подключение',
|
||||
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
|
||||
'ai.externalMcp.allowHost': 'Этот сервер',
|
||||
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
@@ -302,21 +333,24 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': 'Другие',
|
||||
'ai.externalMcp.cliCommand': 'CLI-команда',
|
||||
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
|
||||
'ai.externalMcp.universalSetupPrompt': 'Универсальный запрос установки',
|
||||
'ai.externalMcp.addToCodex': 'Добавить в Codex',
|
||||
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Добавить в Grok',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.codexAdded': 'Запись MCP для Codex и навык Netcatty добавлены. Перезапустите Codex или откройте новую сессию.',
|
||||
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code и навык Netcatty добавлены. Перезапустите Claude Code или откройте новую сессию.',
|
||||
'ai.externalMcp.grokAdded': 'Запись MCP для Grok и навык Netcatty добавлены. Перезапустите Grok или откройте новую сессию.',
|
||||
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
|
||||
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
|
||||
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
|
||||
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
|
||||
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': 'Добавьте этот фрагмент в конфигурацию MCP Cursor, сохранив существующие серверы.',
|
||||
'ai.externalMcp.other.description': 'Скопируйте запрос установки в другой терминальный агент или добавьте фрагмент MCP в конфигурацию его клиента.',
|
||||
'ai.externalMcp.status.unavailable': 'Недоступно',
|
||||
'ai.externalMcp.status.disabled': 'Выключено',
|
||||
'ai.externalMcp.status.running': 'Работает',
|
||||
@@ -423,9 +457,14 @@ 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': 'Загрузка моделей...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Актуальный список моделей',
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
@@ -435,13 +474,21 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.undoLastTurn': 'Отменить последний ход',
|
||||
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
|
||||
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.forkFromHere': 'Ветвление отсюда',
|
||||
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
@@ -449,6 +496,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
@@ -527,6 +581,11 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
|
||||
'terminal.layer.choosePanelPosition': 'Изменить положение',
|
||||
'terminal.layer.dockLeft': 'Слева',
|
||||
'terminal.layer.dockRight': 'Справа',
|
||||
'terminal.layer.dockBottom': 'Снизу',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sort.newest': 'Сначала новые',
|
||||
'sort.oldest': 'Сначала старые',
|
||||
'sort.group': 'По группе',
|
||||
'sort.ip': 'По IP',
|
||||
'field.label': 'Метка',
|
||||
'field.type': 'Тип',
|
||||
'auth.keyType': 'Тип {type}',
|
||||
@@ -76,7 +77,7 @@ export const ruCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Выбрать хосты',
|
||||
'placeholder.workspaceName': 'Имя рабочего пространства',
|
||||
'placeholder.sessionName': 'Имя сессии',
|
||||
'placeholder.searchHosts': 'Поиск хостов...',
|
||||
'placeholder.searchHosts': 'Поиск хостов или тегов...',
|
||||
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
|
||||
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
@@ -295,8 +296,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
@@ -506,7 +510,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
'settings.appearance.appIcon.bright': 'Яркий',
|
||||
'settings.appearance.appIcon.dark': 'Тёмный',
|
||||
@@ -581,6 +585,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
|
||||
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
|
||||
'settings.terminal.font.ligatures': 'Лигатуры шрифта',
|
||||
'settings.terminal.font.ligatures.desc': 'Показывать лигатуры вроде !=, если шрифт терминала их содержит',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
@@ -588,6 +594,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': 'Блок',
|
||||
'settings.terminal.cursor.style.bar': 'Полоса',
|
||||
'settings.terminal.cursor.style.underline': 'Подчёркивание',
|
||||
'settings.terminal.cursor.barWidth': 'Толщина курсора-полосы',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ширина курсоров-полос, используемых приложениями вроде vi (1-4 пикс.)',
|
||||
'settings.terminal.cursor.blink': 'Мигание курсора',
|
||||
'settings.terminal.cursor.highlightLine': 'Подсветка текущей строки',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -606,6 +614,8 @@ export const ruCoreMessages: Messages = {
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
|
||||
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': 'Удержание правой кнопки открывает меню',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Если правая кнопка вставляет текст или выделяет слово, удерживайте её полсекунды для открытия меню. Не влияет на приложения, захватывающие мышь.',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
|
||||
@@ -642,12 +652,21 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Спрашивать перед вставкой нескольких строк',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Перед вставкой нескольких строк показывать диалог подтверждения с количеством строк и символов, редактируемым предпросмотром и действиями «Отправить» / «Отправить построчно» / «Отмена». Защищает CLI сетевых устройств (Cisco IOS, Huawei VRP, H3C Comware), не поддерживающих bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Порог подтверждения (строк)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Запрашивать подтверждение, если вставка содержит не меньше этого числа строк.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
@@ -663,6 +682,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 и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
@@ -694,6 +718,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Анимировать прокрутку области терминала вместо мгновенного перехода',
|
||||
'settings.terminal.sidePanel.pane.browser': 'Браузер',
|
||||
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
|
||||
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
|
||||
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
|
||||
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
|
||||
@@ -739,6 +768,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
|
||||
'settings.terminal.section.localShellSidePanel': 'Боковая панель локальной оболочки',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': 'Развернуть боковую панель при открытии локальной оболочки',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Автоматически открывать боковую панель после открытия локальной оболочки.',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': 'Боковая панель по умолчанию',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
@@ -878,6 +913,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
|
||||
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
@@ -1019,6 +1055,10 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': 'Удалить группу',
|
||||
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
|
||||
'vault.groups.deleteDialog.mixedDesc': 'Эта группа содержит хосты, импортированные из связанного SSH-конфига. Все хосты под управляемой подгруппой будут удалены, включая хосты, не управляемые этим файлом. Остальные хосты будут перемещены в корень, если вы также не удалите их ниже.',
|
||||
'vault.groups.deleteDialog.managedWarning': 'При удалении этой группы её записи также будут удалены из связанного файла конфигурации SSH. Записи, импортированные из этого файла, могут быть утеряны, это затронет и другие приложения, использующие файл. Сделайте резервную копию файла перед продолжением.',
|
||||
'vault.groups.deleteDialog.sourcesChanged': 'Управляемые файлы, затрагиваемые этим удалением, изменились. Проверьте файлы и подтвердите удаление снова.',
|
||||
'vault.groups.deleteDialog.managedFile': 'Связанный файл: {file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
|
||||
'vault.groups.deleteDialog.bulkTitle': 'Удалить выбранные группы',
|
||||
'vault.groups.deleteDialog.bulkDesc': 'Выбранные группы будут безвозвратно удалены, а их хосты перемещены в корень.',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const ruScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
|
||||
'scripts.actions.copyCommand': 'Копировать команду',
|
||||
'scripts.actions.copyCommand.copied': 'Команда скопирована',
|
||||
'scripts.actions.copyCommand.failed': 'Не удалось скопировать команду',
|
||||
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
|
||||
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
|
||||
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
'terminal.pasteConfirm.title': 'Вставить несколько строк?',
|
||||
'terminal.pasteConfirm.summary': 'В сессию будет отправлено строк: {lines} (символов: {chars}).',
|
||||
'terminal.pasteConfirm.preview': 'Предпросмотр (можно изменить)',
|
||||
'terminal.pasteConfirm.send': 'Отправить',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Отправить построчно',
|
||||
'terminal.pasteConfirm.cancel': 'Отмена',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
@@ -32,6 +41,9 @@ export const ruTerminalMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.browser.title': 'Браузер',
|
||||
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
|
||||
'terminal.browser.open': 'Открыть браузер',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
|
||||
@@ -56,6 +68,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'history.action.delete': 'Удалить из истории',
|
||||
'history.action.clearAll': 'Очистить всю историю',
|
||||
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
|
||||
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
|
||||
'history.clearAll.dialog.confirm': 'Очистить всё',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
@@ -73,6 +89,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
@@ -112,6 +129,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
@@ -145,6 +163,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.cut': 'Вырезать',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
@@ -199,6 +218,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
@@ -207,6 +227,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с',
|
||||
'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя',
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.connected': 'Подключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
@@ -269,6 +290,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': 'Чёрный',
|
||||
@@ -313,8 +335,11 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Подключение...',
|
||||
'cloudSync.provider.disconnect': 'Отключить',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
|
||||
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'Совместимое с S3',
|
||||
@@ -384,6 +409,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Стратегия синхронизации',
|
||||
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
|
||||
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
|
||||
'cloudSync.aiSessions.title': 'Синхронизировать историю ИИ-чатов',
|
||||
'cloudSync.aiSessions.desc': 'Включать сессии ИИ-чатов в облачную синхронизацию. Сообщения объединяются дополнением между устройствами; содержимое вложений остаётся на этом устройстве.',
|
||||
'cloudSync.aiSessions.compatibility': 'Старые версии могут перезаписать историю. Обновите Netcatty на всех синхронизируемых устройствах.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Не удалось изменить синхронизацию чатов. Проверьте подключение к облаку и свободное место на устройстве, затем повторите попытку.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Умное слияние (без удалений)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Объединяет изменения с обеих сторон и никогда не передаёт удаление с одной стороны: запись сохраняется, пока её не убрали на всех устройствах. Рекомендуется при восстановлении из резервных копий или при работе с несколькими машинами.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
|
||||
'cloudSync.strategy.preferCloud': 'Приоритет облака',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
@@ -521,6 +552,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
|
||||
'cloudSync.conflict.entity.settings': 'Настройки',
|
||||
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
|
||||
@@ -652,6 +684,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToLeft': 'Закрыть вкладки слева',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
@@ -814,6 +847,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.backspaceBehavior': 'Клавиша Backspace',
|
||||
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
|
||||
'serial.encoding.pendingInput': 'Отправьте или очистите текущий ввод перед сменой кодировки.',
|
||||
'serial.field.byteOrientedBackspace': 'Побайтовый Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Отправляет по одному DEL на байт, чтобы устройство удалило весь многобайтовый символ (для устройств, удаляющих побайтово). Отключите для редакторов, удаляющих посимвольно (например, readline в Linux).',
|
||||
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
@@ -827,6 +863,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
|
||||
'serial.connectAndSave': 'Подключить и сохранить',
|
||||
'serial.edit.title': 'Настройки последовательного порта',
|
||||
'serial.field.username': 'Имя пользователя',
|
||||
'serial.field.password': 'Пароль',
|
||||
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',
|
||||
'serial.field.clearPassword': 'Очистить сохранённый пароль',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Требуется аутентификация',
|
||||
@@ -853,6 +893,13 @@ export const ruTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Перенос строк',
|
||||
'sftp.editor.maximize': 'Развернуть',
|
||||
'sftp.editor.popOut': 'Открыть в окне редактора',
|
||||
'sftp.editor.dock': 'Вернуть в главное окно',
|
||||
'sftp.editor.focusWindow': 'Перейти к окну редактора',
|
||||
'sftp.editor.close': 'Закрыть',
|
||||
'sftp.editor.windowTitle': 'Редактор',
|
||||
'sftp.editor.windowEmpty': 'Нет открытых файлов',
|
||||
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
|
||||
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
|
||||
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
|
||||
'sftp.editor.discardChanges': 'Отбросить',
|
||||
|
||||
@@ -102,12 +102,14 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.tree.expandAll': 'Развернуть все',
|
||||
'vault.tree.collapseAll': 'Свернуть все',
|
||||
'vault.hosts.newHost': 'Новый хост',
|
||||
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
|
||||
'vault.hosts.newGroup': 'Новая группа',
|
||||
'vault.hosts.import': 'Импорт',
|
||||
'vault.hosts.export': 'Экспорт',
|
||||
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': 'Не удалось прочитать {count} сохранённых парольных фраз ключей; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': 'Не удалось прочитать {count} сохранённых паролей прокси; они оставлены пустыми',
|
||||
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
|
||||
'vault.hosts.allHosts': 'Все хосты',
|
||||
'vault.hosts.pinned': 'Закреплённые',
|
||||
@@ -126,6 +128,9 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -154,6 +159,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 +309,8 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Остановить и удалить',
|
||||
'pf.form.autoStart': 'Автозапуск',
|
||||
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
|
||||
'pf.form.autoReconnect': 'Автопереподключение',
|
||||
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Новая папка',
|
||||
@@ -437,6 +449,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Передать следующей',
|
||||
'sftp.transferCenter.pauseAll': 'Приостановить все',
|
||||
'sftp.transferCenter.resumeAll': 'Продолжить все',
|
||||
'sftp.transferCenter.cancelAll': 'Отменить все',
|
||||
'sftp.transferCenter.empty': 'В этой категории нет передач',
|
||||
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
|
||||
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
|
||||
@@ -744,11 +757,25 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.title.new': 'Новый хост',
|
||||
'vault.panel.resizeWidth': 'Изменить ширину панели',
|
||||
'hostDetails.saveAria': 'Сохранить',
|
||||
'hostConnectionTest.title': 'Проверка подключения',
|
||||
'hostConnectionTest.test': 'Проверить',
|
||||
'hostConnectionTest.chainOf': 'Цепочка {current} из {total}',
|
||||
'hostConnectionTest.success': 'Подключение успешно',
|
||||
'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.',
|
||||
'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с',
|
||||
'hostConnectionTest.failed': 'Не удалось подключиться.',
|
||||
'hostConnectionTest.retry': 'Повторить',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.',
|
||||
'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',
|
||||
@@ -1028,6 +1055,21 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Добавить тег...',
|
||||
'hostDetails.createTag': 'Создать тег',
|
||||
'hostDetails.createGroup': 'Создать группу',
|
||||
// Записи локальной оболочки (CMD) в хранилище
|
||||
'localShell.subtitle': 'Локальная оболочка',
|
||||
'localShell.panel.title': 'Новая локальная оболочка',
|
||||
'localShell.panel.title.edit': 'Изменить локальную оболочку',
|
||||
'localShell.field.label': 'Название',
|
||||
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
|
||||
'localShell.field.shell': 'Оболочка',
|
||||
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
|
||||
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
|
||||
'localShell.field.shellEmpty': 'Оболочки не найдены',
|
||||
'localShell.field.shellResolved': 'Запускает {command}',
|
||||
'localShell.field.startDir': 'Начальный каталог',
|
||||
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
|
||||
'localShell.saveAndConnect': 'Сохранить и открыть',
|
||||
|
||||
'hostDetails.plugin.title': 'Подключение плагина',
|
||||
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
|
||||
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
@@ -83,6 +85,21 @@ test("localized settings include OSC desktop notification labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include bar cursor width labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.cursor.barWidth",
|
||||
"settings.terminal.cursor.barWidth.desc",
|
||||
];
|
||||
|
||||
for (const locale of [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
]) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing bar cursor width labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const UNDO_KEYS = [
|
||||
'ai.chat.undoLastTurn',
|
||||
'ai.chat.undoLastTurnNotice',
|
||||
] as const;
|
||||
|
||||
test('Undo-last-turn UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = UNDO_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'vault.groups.deleteDialog.managedDesc',
|
||||
'vault.groups.deleteDialog.mixedDesc',
|
||||
'vault.groups.deleteDialog.managedWarning',
|
||||
'vault.groups.deleteDialog.managedFile',
|
||||
] as const;
|
||||
|
||||
test('managed group delete warning copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing managed group delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('managed group delete warning mentions data loss in every locale', () => {
|
||||
const MARKERS = ['备份', '備份', 'copia de seguridad', 'резервную копию', 'Back up'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const text = messages['vault.groups.deleteDialog.managedWarning'];
|
||||
assert.match(
|
||||
text ?? '',
|
||||
/SSH/,
|
||||
`${locale} warning should reference the SSH config file`,
|
||||
);
|
||||
assert.ok(
|
||||
MARKERS.some((marker) => text?.includes(marker)),
|
||||
`${locale} warning should tell the user to back up the file`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('linked file line interpolates the file path', () => {
|
||||
for (const messages of [en, ru, es, zhCN, zhTW]) {
|
||||
assert.match(
|
||||
messages['vault.groups.deleteDialog.managedFile'] ?? '',
|
||||
/\{file\}/,
|
||||
'managedFile copy should include the {file} placeholder',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重试',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已压缩',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活动',
|
||||
'ai.chat.activity.plan': '计划',
|
||||
@@ -53,6 +57,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 +238,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '默认',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大轮次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
|
||||
'ai.codebuddy.maxBudget': '预算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
|
||||
'ai.codebuddy.persistSession': '会话持久化',
|
||||
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '文件检查点',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
|
||||
@@ -273,6 +290,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安装后在它自己的配置里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.mimo.detecting': '检测中...',
|
||||
'ai.mimo.detected': '已检测到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路径:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。请安装或在下方指定可执行文件路径。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '检查',
|
||||
'ai.mimo.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
@@ -309,6 +338,13 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始终',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
|
||||
'ai.externalMcp.allowAll': '允许所有操作',
|
||||
'ai.externalMcp.allowSession': '此次连接',
|
||||
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此服务器',
|
||||
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -350,21 +386,24 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': '其他',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '配置片段',
|
||||
'ai.externalMcp.universalSetupPrompt': '通用安装提示词',
|
||||
'ai.externalMcp.addToCodex': '添加到 Codex',
|
||||
'ai.externalMcp.addToClaude': '添加到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '添加到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目和 Netcatty Skill。请重启 Codex 或打开新会话。',
|
||||
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目和 Netcatty Skill。请重启 Claude Code 或打开新会话。',
|
||||
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目和 Netcatty Skill。请重启 Grok 或打开新会话。',
|
||||
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
|
||||
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
|
||||
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
|
||||
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': '将片段合并到 Cursor MCP 配置中,并保留已有服务器。',
|
||||
'ai.externalMcp.other.description': '将安装提示词复制到其他终端 Agent,或把 MCP 片段合并到对应客户端配置。',
|
||||
'ai.externalMcp.status.unavailable': '不可用',
|
||||
'ai.externalMcp.status.disabled': '已关闭',
|
||||
'ai.externalMcp.status.running': '运行中',
|
||||
@@ -471,9 +510,14 @@ 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': '正在加载模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '实时目录',
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
@@ -483,13 +527,21 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.undoLastTurn': '撤销上一轮',
|
||||
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.searchSessions': '搜索标题和近期内容…',
|
||||
'ai.chat.clearSessionSearch': '清空会话搜索',
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.forkFromHere': '从此处分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
@@ -497,6 +549,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
@@ -575,8 +634,16 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '改变位置',
|
||||
'terminal.layer.dockLeft': '左侧',
|
||||
'terminal.layer.dockRight': '右侧',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
|
||||
'terminal.layer.layoutSaveFailed': '布局保存失败',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'sort.ip': '按 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -61,6 +62,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'field.name': '名称',
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'placeholder.searchHosts': '搜索主机或标签...',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
@@ -118,6 +120,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.terminal.sidePanel.pane.system': '系统',
|
||||
'settings.terminal.sidePanel.pane.notes': '笔记',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': '本地 Shell 侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': '打开本地 Shell 时展开侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': '打开本地 Shell 后自动打开一个侧边栏。',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': '默认侧边栏',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
|
||||
'settings.terminal.localShellSidePanel.pane.files': '文件',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
@@ -308,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
|
||||
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
|
||||
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
@@ -534,7 +545,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -673,6 +684,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.mixedDesc': '该分组包含从关联 SSH config 文件导入的主机。其托管子分组下的所有主机都将被删除,包括不受该文件管理的主机;其余主机将移动到根级别,除非您在下方选择同时删除。',
|
||||
'vault.groups.deleteDialog.managedWarning': '删除该分组会同时清空关联 SSH config 文件中的对应条目,从该文件导入的配置可能会丢失,其他使用该文件的应用也会受到影响。请先备份文件再继续。',
|
||||
'vault.groups.deleteDialog.sourcesChanged': '此次删除涉及的托管文件已发生变化。请检查文件并重新确认。',
|
||||
'vault.groups.deleteDialog.managedFile': '关联文件:{file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.deleteDialog.bulkTitle': '删除所选分组',
|
||||
'vault.groups.deleteDialog.bulkDesc': '这将永久删除所选分组,并把其中的主机移动到根级别。',
|
||||
@@ -725,12 +740,14 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newLocalShell': '本地终端 (CMD)',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 个已保存的密钥口令无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 个已保存的代理密码无法读取,导出时已留空',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
@@ -749,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
@@ -781,6 +801,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} 导入...',
|
||||
@@ -1030,6 +1055,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '优先传输',
|
||||
'sftp.transferCenter.pauseAll': '全部暂停',
|
||||
'sftp.transferCenter.resumeAll': '全部继续',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '当前分类没有传输任务',
|
||||
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
|
||||
'sftp.transferCenter.hideBackground': '隐藏后台任务',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
'scripts.actions.runOnAllTabs': '在所有标签页运行',
|
||||
'scripts.actions.copyCommand': '复制命令',
|
||||
'scripts.actions.copyCommand.copied': '命令已复制',
|
||||
'scripts.actions.copyCommand.failed': '复制命令失败',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 个标签处于密码/敏感输入,已跳过',
|
||||
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
'terminal.pasteConfirm.title': '要粘贴多行文本吗?',
|
||||
'terminal.pasteConfirm.summary': '将向会话发送 {lines} 行({chars} 个字符)。',
|
||||
'terminal.pasteConfirm.preview': '预览(可编辑)',
|
||||
'terminal.pasteConfirm.send': '发送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行发送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
@@ -54,6 +63,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
'history.action.delete': '从历史记录中删除',
|
||||
'history.action.clearAll': '清空全部历史',
|
||||
'history.clearAll.dialog.title': '清空全部命令历史?',
|
||||
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -269,6 +282,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字体平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
|
||||
'settings.terminal.font.ligatures': '字体连字',
|
||||
'settings.terminal.font.ligatures.desc': '支持连字的字体把 != 这类字符显示成一个符号',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
@@ -276,6 +291,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '块',
|
||||
'settings.terminal.cursor.style.bar': '竖线',
|
||||
'settings.terminal.cursor.style.underline': '下划线',
|
||||
'settings.terminal.cursor.barWidth': '竖线光标粗细',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等应用使用竖线光标时的宽度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.cursor.highlightLine': '高亮当前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '为光标所在行加上淡色背景,方便定位当前输入位置',
|
||||
@@ -289,6 +306,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右键长按显示菜单',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右键设为粘贴或选词时,按住半秒打开菜单。不影响接管鼠标的终端应用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
|
||||
@@ -322,12 +341,21 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '粘贴多行前先询问',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'粘贴多行文本时先弹出确认对话框,显示行数和字符数、可编辑的预览,并提供发送 / 逐行发送 / 取消操作。可保护不支持括号粘贴的网络设备 CLI(Cisco IOS、华为 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '确认阈值(行数)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'粘贴内容达到该行数时才弹出确认对话框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
@@ -343,6 +371,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 用它提示回合完成或等待审批。',
|
||||
@@ -370,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.sidePanel.pane.browser': '浏览器',
|
||||
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
@@ -565,6 +603,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
@@ -722,6 +761,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToLeft': '关闭左侧标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +932,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.backspaceBehavior': '回退键',
|
||||
'serial.field.backspaceBehaviorDesc': '如果网络设备不响应默认回退键,请改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '请先提交或清空当前输入,再切换编码。',
|
||||
'serial.field.byteOrientedBackspace': '按字节退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '发送与字符字节数相同的 DEL,使设备删除整个多字节字符(适用于按字节删除的设备)。若为逐字符删除的行编辑器(如 Linux readline),请关闭。',
|
||||
'serial.backspace.default': '默认(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字符编码',
|
||||
@@ -905,6 +948,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
'serial.field.username': '用户名',
|
||||
'serial.field.password': '密码',
|
||||
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',
|
||||
'serial.field.clearPassword': '清除已保存的密码',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
@@ -931,6 +978,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在编辑器窗口打开',
|
||||
'sftp.editor.dock': '停靠回主窗口',
|
||||
'sftp.editor.focusWindow': '聚焦编辑器窗口',
|
||||
'sftp.editor.close': '关闭',
|
||||
'sftp.editor.windowTitle': '编辑器',
|
||||
'sftp.editor.windowEmpty': '没有打开的文件',
|
||||
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
|
||||
@@ -130,11 +130,25 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.title.new': '新建主机',
|
||||
'vault.panel.resizeWidth': '调整面板宽度',
|
||||
'hostDetails.saveAria': '保存',
|
||||
'hostConnectionTest.title': '测试连接',
|
||||
'hostConnectionTest.test': '测试',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '连接成功',
|
||||
'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。',
|
||||
'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时',
|
||||
'hostConnectionTest.failed': '连接失败。',
|
||||
'hostConnectionTest.retry': '重试',
|
||||
'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。',
|
||||
'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',
|
||||
@@ -376,6 +390,21 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
// 本地终端(CMD)主机条目
|
||||
'localShell.subtitle': '本地终端',
|
||||
'localShell.panel.title': '新建本地终端',
|
||||
'localShell.panel.title.edit': '编辑本地终端',
|
||||
'localShell.field.label': '名称',
|
||||
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系统默认 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未发现可用 Shell',
|
||||
'localShell.field.shellResolved': '启动 {command}',
|
||||
'localShell.field.startDir': '启动目录',
|
||||
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
|
||||
'localShell.saveAndConnect': '保存并打开',
|
||||
|
||||
'hostDetails.plugin.title': '插件连接',
|
||||
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
|
||||
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
|
||||
@@ -439,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.browser.title': '浏览器',
|
||||
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
|
||||
'terminal.browser.open': '打开浏览器',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -461,6 +493,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
@@ -498,6 +531,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
@@ -531,6 +565,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.cut': '剪切',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
@@ -573,6 +608,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
@@ -582,6 +618,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.waitingForUserInput': '等待用户输入',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.connected': '已连接',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
@@ -639,6 +676,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': '黑色',
|
||||
@@ -682,8 +720,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '连接中...',
|
||||
'cloudSync.provider.disconnect': '断开连接',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '断开连接',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
|
||||
'cloudSync.provider.s3': 'S3 兼容存储',
|
||||
@@ -753,6 +794,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
|
||||
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 对话记录',
|
||||
'cloudSync.aiSessions.desc': '把 AI 对话记录纳入云同步。多设备的对话会按追加方式合并,附件内容仍保留在本机。',
|
||||
'cloudSync.aiSessions.compatibility': '旧版本可能覆盖对话记录,请先更新所有参与同步的设备。',
|
||||
'cloudSync.aiSessions.enableFailed': '无法更改聊天同步设置,请检查云端连接和本地可用空间,然后重试。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智能合并(不传播删除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合并两边的变化,且单侧删除不会同步到其它设备——只有所有设备都删掉才会真正消失。恢复备份或多台机器同步时推荐使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
|
||||
'cloudSync.strategy.preferCloud': '云端优先',
|
||||
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
@@ -889,6 +936,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
|
||||
'cloudSync.conflict.entity.groupConfigs': '分组设置',
|
||||
'cloudSync.conflict.entity.settings': '设置',
|
||||
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
|
||||
@@ -974,6 +1022,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': '本地',
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.modelCatalogRetry': '重試',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
|
||||
'ai.chat.contextCompacted': '上下文已壓縮',
|
||||
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Agent 活動',
|
||||
'ai.chat.activity.plan': '計畫',
|
||||
@@ -225,20 +229,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars': '環境變數',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
|
||||
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
|
||||
'ai.codebuddy.effort': '推理力度',
|
||||
'ai.codebuddy.effort.default': '預設',
|
||||
'ai.codebuddy.effort.minimal': 'Minimal',
|
||||
'ai.codebuddy.effort.low': 'Low',
|
||||
'ai.codebuddy.effort.medium': 'Medium',
|
||||
'ai.codebuddy.effort.high': 'High',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
|
||||
'ai.codebuddy.effort.max': 'Max',
|
||||
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
|
||||
'ai.codebuddy.maxTurns': '最大輪次',
|
||||
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
|
||||
'ai.codebuddy.maxBudget': '預算上限 (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
|
||||
'ai.codebuddy.sandbox': '沙箱模式',
|
||||
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
|
||||
'ai.codebuddy.persistSession': '工作階段持久化',
|
||||
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
|
||||
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
|
||||
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
|
||||
@@ -273,6 +281,18 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安裝後在它自己的設定裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.mimo.detecting': '偵測中...',
|
||||
'ai.mimo.detected': '已偵測到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路徑:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '檢查',
|
||||
'ai.mimo.resetPath': '重設',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
|
||||
@@ -308,6 +328,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始終',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
|
||||
'ai.externalMcp.allowAll': '允許所有操作',
|
||||
'ai.externalMcp.allowSession': '此次連線',
|
||||
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此伺服器',
|
||||
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -349,21 +376,24 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.client.other': '其他',
|
||||
'ai.externalMcp.cliCommand': 'CLI 命令',
|
||||
'ai.externalMcp.configSnippet': '設定片段',
|
||||
'ai.externalMcp.universalSetupPrompt': '通用安裝提示詞',
|
||||
'ai.externalMcp.addToCodex': '新增到 Codex',
|
||||
'ai.externalMcp.addToClaude': '新增到 Claude Code',
|
||||
'ai.externalMcp.addToGrok': '新增到 Grok',
|
||||
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
|
||||
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
|
||||
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
|
||||
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目和 Netcatty Skill。請重新啟動 Codex 或開啟新工作階段。',
|
||||
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目和 Netcatty Skill。請重新啟動 Claude Code 或開啟新工作階段。',
|
||||
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目和 Netcatty Skill。請重新啟動 Grok 或開啟新工作階段。',
|
||||
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
|
||||
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
|
||||
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
|
||||
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
|
||||
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
|
||||
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
|
||||
'ai.externalMcp.cursor.title': 'Cursor',
|
||||
'ai.externalMcp.cursor.description': '將片段合併到 Cursor MCP 設定中,並保留已有伺服器。',
|
||||
'ai.externalMcp.other.description': '將安裝提示詞複製到其他終端 Agent,或把 MCP 片段合併到對應用戶端設定。',
|
||||
'ai.externalMcp.status.unavailable': '無法使用',
|
||||
'ai.externalMcp.status.disabled': '已關閉',
|
||||
'ai.externalMcp.status.running': '執行中',
|
||||
@@ -470,9 +500,14 @@ 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': '正在載入模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '即時目錄',
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
@@ -482,13 +517,21 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.undoLastTurn': '復原上一輪',
|
||||
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.searchSessions': '搜尋標題和近期內容…',
|
||||
'ai.chat.clearSessionSearch': '清空會話搜尋',
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.forkFromHere': '從此處分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
@@ -496,6 +539,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuMentionNote': '提及筆記',
|
||||
'ai.chat.mentionNoteSearch': '搜尋筆記…',
|
||||
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
|
||||
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
|
||||
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
|
||||
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
|
||||
'ai.chat.untitledNote': '未命名筆記',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
@@ -574,8 +624,16 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '變更位置',
|
||||
'terminal.layer.dockLeft': '左側',
|
||||
'terminal.layer.dockRight': '右側',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
|
||||
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sort.newest': '從新到舊',
|
||||
'sort.oldest': '從舊到新',
|
||||
'sort.group': '依群組',
|
||||
'sort.ip': '依 IP',
|
||||
'field.label': 'Label',
|
||||
'field.type': '型別',
|
||||
'auth.keyType': '型別 {type}',
|
||||
@@ -63,7 +64,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'field.selectHosts': '選擇主機',
|
||||
'placeholder.workspaceName': '工作區名稱',
|
||||
'placeholder.sessionName': '工作階段名稱',
|
||||
'placeholder.searchHosts': '搜尋主機...',
|
||||
'placeholder.searchHosts': '搜尋主機或標籤...',
|
||||
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
|
||||
'credentials.protectionUnavailable.title': '憑證保護不可用',
|
||||
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
|
||||
@@ -121,6 +122,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.terminal.sidePanel.pane.system': '系統',
|
||||
'settings.terminal.sidePanel.pane.notes': '筆記',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.section.localShellSidePanel': '本機 Shell 側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpen': '開啟本機 Shell 時展開側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpen.desc': '開啟本機 Shell 後自動開啟一個側邊欄。',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane': '預設側邊欄',
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
|
||||
'settings.terminal.localShellSidePanel.pane.files': '檔案',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系統',
|
||||
@@ -311,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
|
||||
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
|
||||
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
@@ -537,7 +547,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -676,6 +686,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.groups.deleteDialogTitle': '刪除群組',
|
||||
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
|
||||
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
|
||||
'vault.groups.deleteDialog.mixedDesc': '該群組包含從關聯 SSH config 檔案匯入的主機。其受管理子群組下的所有主機都將被刪除,包括不受該檔案管理的主機;其餘主機將移動到根層級,除非您在下方選擇同時刪除。',
|
||||
'vault.groups.deleteDialog.managedWarning': '刪除該群組會同時清空關聯 SSH config 檔案中的對應條目,從該檔案匯入的設定可能會遺失,其他使用該檔案的應用也會受到影響。請先備份檔案再繼續。',
|
||||
'vault.groups.deleteDialog.sourcesChanged': '此次刪除涉及的受管理檔案已發生變化。請檢查檔案並重新確認。',
|
||||
'vault.groups.deleteDialog.managedFile': '關聯檔案:{file}',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
|
||||
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
|
||||
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
|
||||
@@ -728,12 +742,14 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展開全部',
|
||||
'vault.tree.collapseAll': '摺疊全部',
|
||||
'vault.hosts.newHost': '新增主機',
|
||||
'vault.hosts.newLocalShell': '本地終端 (CMD)',
|
||||
'vault.hosts.newGroup': '新增群組',
|
||||
'vault.hosts.import': '匯入',
|
||||
'vault.hosts.export': '匯出',
|
||||
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
|
||||
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 個已儲存的金鑰密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 個已儲存的代理密碼無法讀取,匯出時已留空',
|
||||
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
|
||||
'vault.hosts.allHosts': '全部主機',
|
||||
'vault.hosts.pinned': '已置頂',
|
||||
@@ -752,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
@@ -784,6 +803,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} 匯入...',
|
||||
@@ -1033,6 +1057,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '優先傳輸',
|
||||
'sftp.transferCenter.pauseAll': '全部暫停',
|
||||
'sftp.transferCenter.resumeAll': '全部繼續',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
|
||||
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
|
||||
'sftp.transferCenter.hideBackground': '隱藏背景工作',
|
||||
|
||||
@@ -27,12 +27,17 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有分頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有分頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有分頁執行',
|
||||
'scripts.actions.copyCommand': '複製指令',
|
||||
'scripts.actions.copyCommand.copied': '指令已複製',
|
||||
'scripts.actions.copyCommand.failed': '複製指令失敗',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
'terminal.pasteConfirm.title': '要貼上多行文字嗎?',
|
||||
'terminal.pasteConfirm.summary': '將向工作階段傳送 {lines} 行({chars} 個字元)。',
|
||||
'terminal.pasteConfirm.preview': '預覽(可編輯)',
|
||||
'terminal.pasteConfirm.send': '傳送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行傳送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
@@ -54,6 +63,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'history.action.run': '在終端執行',
|
||||
'history.action.saveAsSnippet': '儲存為程式碼片段',
|
||||
'history.action.delete': '從歷史記錄中刪除',
|
||||
'history.action.clearAll': '清空全部歷史',
|
||||
'history.clearAll.dialog.title': '清空全部指令歷史?',
|
||||
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '複製檔案路徑',
|
||||
'sftp.context.openWith': '開啟方式...',
|
||||
@@ -269,6 +282,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字型平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
|
||||
'settings.terminal.font.ligatures': '字型連字',
|
||||
'settings.terminal.font.ligatures.desc': '支援連字的字型把 != 這類字元顯示成一個符號',
|
||||
'settings.terminal.font.linePadding': '行間距',
|
||||
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '終端模擬型別',
|
||||
@@ -276,6 +291,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '塊',
|
||||
'settings.terminal.cursor.style.bar': '豎線',
|
||||
'settings.terminal.cursor.style.underline': '下劃線',
|
||||
'settings.terminal.cursor.barWidth': '豎線游標粗細',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等應用程式使用豎線游標時的寬度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '游標閃爍',
|
||||
'settings.terminal.cursor.highlightLine': '高亮目前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '為游標所在行加上淡色背景,方便定位目前輸入位置',
|
||||
@@ -289,6 +306,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右鍵行為',
|
||||
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu': '右鍵長按顯示選單',
|
||||
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右鍵設為貼上或選詞時,按住半秒開啟選單。不影響接管滑鼠的終端應用。',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
|
||||
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
|
||||
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
|
||||
@@ -322,12 +341,21 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '貼上多行前先詢問',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'貼上多行文字時先彈出確認對話框,顯示行數和字元數、可編輯的預覽,並提供傳送 / 逐行傳送 / 取消操作。可保護不支援括號貼上的網路設備 CLI(Cisco IOS、華為 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '確認門檻(行數)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'貼上內容達到該行數時才彈出確認對話框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
@@ -343,6 +371,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 會用它提示回合完成或等待核准。',
|
||||
@@ -370,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
|
||||
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
|
||||
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
|
||||
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
|
||||
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
|
||||
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
|
||||
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
|
||||
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
|
||||
@@ -565,6 +603,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新增工作區',
|
||||
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
@@ -722,6 +761,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
'tabs.closeToLeft': '關閉左側分頁',
|
||||
'tabs.closeToRight': '關閉右側分頁',
|
||||
'tabs.closeAll': '關閉所有分頁',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +932,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
|
||||
'serial.field.backspaceBehavior': '退格鍵',
|
||||
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '請先提交或清空目前輸入,再切換編碼。',
|
||||
'serial.field.byteOrientedBackspace': '按位元組退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '傳送與字元位元組數相同的 DEL,使裝置刪除整個多位元組字元(適用於按位元組刪除的裝置)。若為逐字元刪除的行編輯器(如 Linux readline),請關閉。',
|
||||
'serial.backspace.default': '預設(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
@@ -905,6 +948,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '連線並儲存',
|
||||
'serial.edit.title': '序列埠設定',
|
||||
'serial.field.username': '使用者名稱',
|
||||
'serial.field.password': '密碼',
|
||||
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',
|
||||
'serial.field.clearPassword': '清除已儲存的密碼',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要驗證',
|
||||
@@ -931,6 +978,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自動換行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.popOut': '在編輯器視窗開啟',
|
||||
'sftp.editor.dock': '停靠回主視窗',
|
||||
'sftp.editor.focusWindow': '聚焦編輯器視窗',
|
||||
'sftp.editor.close': '關閉',
|
||||
'sftp.editor.windowTitle': '編輯器',
|
||||
'sftp.editor.windowEmpty': '沒有開啟的檔案',
|
||||
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
|
||||
'sftp.editor.unsavedTitle': '未儲存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
|
||||
'sftp.editor.discardChanges': '不儲存',
|
||||
|
||||
@@ -129,11 +129,25 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.title.details': '主機詳情',
|
||||
'hostDetails.title.new': '新增主機',
|
||||
'hostDetails.saveAria': '儲存',
|
||||
'hostConnectionTest.title': '測試連線',
|
||||
'hostConnectionTest.test': '測試',
|
||||
'hostConnectionTest.chainOf': '第 {current}/{total} 跳',
|
||||
'hostConnectionTest.success': '連線成功',
|
||||
'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。',
|
||||
'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時',
|
||||
'hostConnectionTest.failed': '連線失敗。',
|
||||
'hostConnectionTest.retry': '重試',
|
||||
'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。',
|
||||
'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',
|
||||
@@ -376,6 +390,21 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '新增標籤...',
|
||||
'hostDetails.createTag': '建立標籤',
|
||||
'hostDetails.createGroup': '建立群組',
|
||||
// 本地終端(CMD)主機項目
|
||||
'localShell.subtitle': '本地終端',
|
||||
'localShell.panel.title': '新增本地終端',
|
||||
'localShell.panel.title.edit': '編輯本地終端',
|
||||
'localShell.field.label': '名稱',
|
||||
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系統預設 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未找到可用的 Shell',
|
||||
'localShell.field.shellResolved': '啟動 {command}',
|
||||
'localShell.field.startDir': '啟動目錄',
|
||||
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
|
||||
'localShell.saveAndConnect': '儲存並開啟',
|
||||
|
||||
'hostDetails.plugin.title': '外掛程式連線',
|
||||
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
|
||||
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
|
||||
@@ -439,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '開啟 SFTP',
|
||||
'terminal.browser.title': '瀏覽器',
|
||||
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
|
||||
'terminal.browser.open': '開啟瀏覽器',
|
||||
'terminal.toolbar.availableAfterConnect': '連線後可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
|
||||
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
|
||||
@@ -461,6 +493,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
@@ -498,6 +531,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
@@ -531,6 +565,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.cut': '剪下',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
@@ -573,6 +608,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
@@ -582,6 +618,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': '將在 {seconds}s 後超時',
|
||||
'terminal.progress.waitingForUserInput': '等待使用者輸入',
|
||||
'terminal.progress.disconnected': '已斷開',
|
||||
'terminal.progress.connected': '已連線',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新開始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示',
|
||||
@@ -639,6 +676,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': '黑色',
|
||||
@@ -682,8 +720,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '連線中...',
|
||||
'cloudSync.provider.disconnect': '中斷連線',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
|
||||
'cloudSync.provider.s3': 'S3 相容儲存',
|
||||
@@ -753,6 +794,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
|
||||
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 對話記錄',
|
||||
'cloudSync.aiSessions.desc': '把 AI 對話記錄納入雲同步。多裝置的對話會按追加方式合併,附件內容仍保留在本機。',
|
||||
'cloudSync.aiSessions.compatibility': '舊版本可能覆寫對話記錄,請先更新所有參與同步的裝置。',
|
||||
'cloudSync.aiSessions.enableFailed': '無法變更聊天同步設定,請檢查雲端連線與本機可用空間,然後重試。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智慧合併(不傳播刪除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合併兩邊的變化,且單側刪除不會同步到其它裝置——只有所有裝置都刪掉才會真正消失。還原備份或多台機器同步時建議使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
|
||||
'cloudSync.strategy.preferCloud': '雲端優先',
|
||||
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
@@ -889,6 +936,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.customGroups': '群組',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
|
||||
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
|
||||
'cloudSync.conflict.entity.groupConfigs': '群組設定',
|
||||
'cloudSync.conflict.entity.settings': '設定',
|
||||
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
|
||||
@@ -974,6 +1022,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -466,6 +466,115 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
// Just over the 8 KiB cap: the truncation marker must not make the degraded
|
||||
// copy larger than the original, or the ladder could evict a session whose
|
||||
// unmodified JSON would have fit the budget.
|
||||
for (const oversize of [8193, 8266, 20000]) {
|
||||
const session = makeSession('near-cap', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
|
||||
}]);
|
||||
const fullJson = JSON.stringify([session]);
|
||||
const result = serializeSessionsForStorage([session], fullJson.length - 10);
|
||||
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
|
||||
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
|
||||
assert.ok(
|
||||
JSON.stringify(result.sessions).length < fullJson.length,
|
||||
`degraded copy smaller than original for ${oversize}`,
|
||||
);
|
||||
assert.ok(content.includes('truncated'));
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'still-open chat',
|
||||
timestamp: 2,
|
||||
}]);
|
||||
const toolBulk = makeSession('tool-bulk', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
|
||||
|
||||
// The bloated tool result must not evict the other visible chat.
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
|
||||
assert.ok(result.json.length <= 100 * 1024);
|
||||
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
|
||||
assert.ok(persistedToolContent);
|
||||
assert.ok(persistedToolContent.length <= 9 * 1024);
|
||||
assert.ok(persistedToolContent.includes('truncated'));
|
||||
assert.ok(persistedToolContent.startsWith('zzz'));
|
||||
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: index,
|
||||
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(
|
||||
persisted.map(session => session.id),
|
||||
sessions.map(session => session.id),
|
||||
);
|
||||
// Degradation sheds only as many messages as the budget demands, so at
|
||||
// least one chat keeps its full tool output while the persisted set stays
|
||||
// complete. Assert completion first; truncation second.
|
||||
const shedContents = persisted.map(
|
||||
session => session.messages[0].toolResults?.[0].content ?? '',
|
||||
);
|
||||
assert.ok(shedContents.some(content => content.includes('truncated')));
|
||||
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
@@ -47,12 +48,10 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<void> {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
}
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
}
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
@@ -195,12 +194,40 @@ const RETRY_SESSIONS_JSON_BYTES = [
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
@@ -218,7 +245,7 @@ function stripReasoningEncryptedContent(
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
@@ -239,6 +266,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
@@ -259,7 +302,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
@@ -310,6 +353,11 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
@@ -321,13 +369,13 @@ export function serializeSessionsForStorage(
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
@@ -339,7 +387,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,11 +412,12 @@ export function serializeSessionsForStorage(
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
@@ -378,27 +427,27 @@ export function serializeSessionsForStorage(
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
@@ -421,6 +470,11 @@ export function serializeSessionsForStorage(
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
|
||||
type StorageListener = (event: StorageEventLike) => void;
|
||||
|
||||
type DeferredRead = { resolve: (value: string) => void };
|
||||
|
||||
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
||||
|
||||
function installLocalStorage(): Map<string, string> {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store registers its cross-window `storage` listener at module load, so
|
||||
* the DOM globals must exist before the dynamic imports below.
|
||||
*/
|
||||
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
|
||||
const listeners = new Set<StorageListener>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
addEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.delete(listener);
|
||||
},
|
||||
netcatty: undefined as unknown,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return (key: string, newValue: string | null, oldValue: string | null = null) => {
|
||||
for (const listener of listeners) listener({ key, newValue, oldValue });
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const storage = installLocalStorage();
|
||||
const dispatchStorageEvent = installWindow();
|
||||
|
||||
const {
|
||||
clearHistory,
|
||||
flushCommandHistoryStore,
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
queryHistory,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
const { seedLocalShellHistoryFromHistfiles } = await import(
|
||||
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
|
||||
);
|
||||
|
||||
function readPersistedCommands(): string[] {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
|
||||
return parsed.entries.map((entry) => entry.command);
|
||||
}
|
||||
|
||||
function readPersistedGeneration(): number {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return 0;
|
||||
const parsed = JSON.parse(raw) as { clearGeneration?: number };
|
||||
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
|
||||
}
|
||||
|
||||
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
|
||||
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
|
||||
|
||||
// No autocomplete query has loaded this renderer's command-history store.
|
||||
const recorded = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: 3,
|
||||
});
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
|
||||
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
|
||||
});
|
||||
|
||||
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
|
||||
clearHistory();
|
||||
recordCommand("old-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
recordCommand("pending-before-clear", "host-1");
|
||||
|
||||
// localStorage is already updated in the peer, but this renderer has not yet
|
||||
// processed its queued storage event when the pending save runs.
|
||||
const generation = readPersistedGeneration() + 1;
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
|
||||
entries: [], version: 1, clearGeneration: generation,
|
||||
}));
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
assert.equal(readPersistedGeneration(), generation);
|
||||
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
|
||||
|
||||
recordCommand("new-after-clear", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
|
||||
});
|
||||
|
||||
test("an ordinary peer write does not cancel a locally pending command", async () => {
|
||||
clearHistory();
|
||||
recordCommand("local-pending-command", "host-1");
|
||||
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
|
||||
const ordinary = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.ok(readPersistedCommands().includes("local-pending-command"));
|
||||
});
|
||||
|
||||
test("an external clear invalidates the cached history and cannot be resurrected", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("stale-command --flag", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
|
||||
|
||||
// Simulate another renderer (main window or a detached terminal popup)
|
||||
// clearing the shared localStorage entry.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
// The stale module-local cache must be dropped immediately.
|
||||
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
|
||||
|
||||
// A later recordCommand must persist only the new command, never the stale
|
||||
// snapshot it still held in memory.
|
||||
recordCommand("fresh-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
|
||||
});
|
||||
|
||||
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("pending-command", "host-1");
|
||||
// No flush: the debounced write is still scheduled.
|
||||
clearHistory();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
|
||||
// Wait past the debounce window to prove the cancelled write cannot restore
|
||||
// the entries.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
});
|
||||
|
||||
test("a local clear bumps the generation and notifies subscribers", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const before = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
recordCommand("notify-local", "host-1");
|
||||
clearHistory();
|
||||
|
||||
assert.equal(getCommandHistoryClearGeneration(), before + 1);
|
||||
assert.equal(readPersistedGeneration(), before + 1);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
|
||||
test("an external clear notifies subscribers, while an external record does not", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const baseline = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// A normal record from another renderer keeps the generation: no clear event.
|
||||
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
assert.equal(notified, 0);
|
||||
|
||||
// A clear from another renderer bumps the generation: subscribers fire.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
assert.equal(notified, 1);
|
||||
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("a histfile import that started before a clear is discarded", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const hostId = `local-seed-clear-${Date.now()}`;
|
||||
const deferredReads: DeferredRead[] = [];
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
deferredReads.push({ resolve });
|
||||
}),
|
||||
};
|
||||
|
||||
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
// Let the seeder resolve getHomeDir and reach the histfile reads.
|
||||
await waitFor(() => deferredReads.length === 4);
|
||||
|
||||
// The user clears history while the bridge reads are still pending.
|
||||
clearHistory();
|
||||
|
||||
for (const deferred of deferredReads) {
|
||||
deferred.resolve(": 1700000000:0;echo seeded\n");
|
||||
}
|
||||
|
||||
assert.equal(await seedPromise, 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
|
||||
// The host is marked seeded so a later open cannot re-import the deleted
|
||||
// commands.
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
|
||||
storage.clear();
|
||||
assert.equal(hasCommandHistoryBeenCleared(), false);
|
||||
|
||||
markCommandHistoryCleared();
|
||||
|
||||
assert.equal(hasCommandHistoryBeenCleared(), true);
|
||||
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
|
||||
});
|
||||
|
||||
test("a first-time histfile import is skipped once history has been cleared", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
markCommandHistoryCleared();
|
||||
|
||||
const hostId = `local-seed-tombstone-${Date.now()}`;
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
|
||||
};
|
||||
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("an external clear also drops in-memory compose-bar recall", async () => {
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
composeBar.pruneComposeBarHistory([]);
|
||||
composeBar.recordComposeBarHistory("session-1", "echo compose");
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// Simulate another renderer clearing command history. The compose-bar store
|
||||
// follows the same clear event, so a detached popup drops its recall too.
|
||||
const cleared = JSON.stringify({
|
||||
entries: [],
|
||||
version: 1,
|
||||
clearGeneration: getCommandHistoryClearGeneration() + 1,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
+154
-12
@@ -2,11 +2,19 @@
|
||||
* Persistent command history store for terminal autocomplete.
|
||||
* Stores commands per host with frequency tracking and timestamp ordering.
|
||||
* Uses localStorageAdapter as the persistence layer (works in renderer process).
|
||||
*
|
||||
* Lives in application state rather than the UI layer so the vault hook and the
|
||||
* terminal UI depend on one lower-layer API, and so every renderer (main window
|
||||
* and detached terminal popups) shares the same clear semantics.
|
||||
*/
|
||||
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
const STORAGE_KEY = "netcatty:commandHistory";
|
||||
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
||||
const MAX_ENTRIES = 10000;
|
||||
const MAX_ENTRIES_PER_HOST = 5000;
|
||||
|
||||
@@ -26,9 +34,21 @@ export interface HistoryEntry {
|
||||
interface HistoryStore {
|
||||
entries: HistoryEntry[];
|
||||
version: number;
|
||||
/**
|
||||
* Monotonic counter bumped by every global clear. Other renderers compare it
|
||||
* to detect a clear performed elsewhere, and in-flight histfile imports use
|
||||
* it to discard work that started before the clear.
|
||||
*/
|
||||
clearGeneration?: number;
|
||||
}
|
||||
|
||||
let cachedStore: HistoryStore | null = null;
|
||||
/** Last clear generation this renderer has observed. */
|
||||
let knownClearGeneration: number | null = null;
|
||||
|
||||
function readClearGeneration(store: HistoryStore): number {
|
||||
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
|
||||
}
|
||||
|
||||
function loadStore(): HistoryStore {
|
||||
if (cachedStore) return cachedStore;
|
||||
@@ -36,18 +56,29 @@ function loadStore(): HistoryStore {
|
||||
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (parsed) {
|
||||
cachedStore = parsed;
|
||||
knownClearGeneration = readClearGeneration(parsed);
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted data, reset
|
||||
}
|
||||
cachedStore = { entries: [], version: 1 };
|
||||
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
|
||||
knownClearGeneration = 0;
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function persistStoreNow(store: HistoryStore): boolean {
|
||||
// A peer clear is visible in storage before its queued event is handled.
|
||||
// Never flush a cached pre-clear snapshot over that newer empty store.
|
||||
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
||||
cachedStore = persisted;
|
||||
knownClearGeneration = readClearGeneration(persisted);
|
||||
notifyCommandHistoryCleared();
|
||||
return true;
|
||||
}
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
if (ok) return true;
|
||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||
@@ -67,6 +98,13 @@ function saveStore(store: HistoryStore): void {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function cancelPendingSave(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending debounced history write immediately.
|
||||
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
|
||||
@@ -75,13 +113,112 @@ function saveStore(store: HistoryStore): void {
|
||||
*/
|
||||
export function flushCommandHistoryStore(): boolean {
|
||||
if (!cachedStore) return true;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
cancelPendingSave();
|
||||
return persistStoreNow(cachedStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a write performed by another renderer (main window or a detached
|
||||
* terminal popup).
|
||||
*
|
||||
* A bumped clear generation drops the cache and pending save, then notifies
|
||||
* live autocomplete controllers. Ordinary peer writes must not cancel a local
|
||||
* command that is still waiting for its debounced save.
|
||||
*/
|
||||
function applyExternalCommandHistoryWrite(
|
||||
rawNewValue: string | null,
|
||||
rawOldValue: string | null,
|
||||
): void {
|
||||
let incomingGeneration: number | null = null;
|
||||
if (rawNewValue) {
|
||||
try {
|
||||
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
||||
} catch {
|
||||
incomingGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
let previousGeneration = knownClearGeneration;
|
||||
if (previousGeneration === null) {
|
||||
// A fresh popup can have compose recall before its first autocomplete query.
|
||||
// The event's old value is its baseline, even after an earlier global clear.
|
||||
try {
|
||||
previousGeneration = rawOldValue
|
||||
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
||||
: 0;
|
||||
} catch {
|
||||
previousGeneration = 0;
|
||||
}
|
||||
}
|
||||
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
||||
cancelPendingSave();
|
||||
cachedStore = null;
|
||||
knownClearGeneration = incomingGeneration;
|
||||
notifyCommandHistoryCleared();
|
||||
} else {
|
||||
if (!saveTimer) cachedStore = null;
|
||||
if (incomingGeneration !== null && knownClearGeneration === null) {
|
||||
knownClearGeneration = incomingGeneration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The native `storage` event fires in *other* same-origin windows (main window
|
||||
// and detached terminal popups) when a key changes there.
|
||||
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Current clear generation. Callers capture this before async work and compare
|
||||
* afterwards to detect a clear that happened while they were waiting.
|
||||
*/
|
||||
export function getCommandHistoryClearGeneration(): number {
|
||||
if (knownClearGeneration === null) loadStore();
|
||||
return knownClearGeneration ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the user explicitly cleared all command history.
|
||||
*
|
||||
* Histfile seeding honors this tombstone: a clear is a user decision to delete
|
||||
* locally recorded history, so a later first-time import must not restore the
|
||||
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
|
||||
* clear that happened before the import started).
|
||||
*/
|
||||
export function markCommandHistoryCleared(): void {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
|
||||
}
|
||||
|
||||
/** True once the user has cleared all command history at least once. */
|
||||
export function hasCommandHistoryBeenCleared(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
|
||||
}
|
||||
|
||||
type CommandHistoryClearedListener = () => void;
|
||||
const clearedListeners = new Set<CommandHistoryClearedListener>();
|
||||
|
||||
/**
|
||||
* Subscribe to command-history clears, including clears performed in another
|
||||
* renderer. Live autocomplete controllers use this to drop visible suggestions
|
||||
* and ghost text and abort in-flight completions, so a stale row cannot be
|
||||
* accepted and recorded after the history was cleared.
|
||||
*/
|
||||
export function subscribeCommandHistoryCleared(
|
||||
listener: CommandHistoryClearedListener,
|
||||
): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => {
|
||||
clearedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyCommandHistoryCleared(): void {
|
||||
for (const listener of [...clearedListeners]) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a command execution. Updates frequency if the command already exists
|
||||
* for this host, otherwise creates a new entry.
|
||||
@@ -149,10 +286,7 @@ export function removeCommandHistoryEntry(command: string, hostId: string): bool
|
||||
if (nextEntries.length === store.entries.length) return false;
|
||||
|
||||
store.entries = nextEntries;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
cancelPendingSave();
|
||||
return persistStoreNow(store);
|
||||
}
|
||||
|
||||
@@ -427,6 +561,8 @@ function fuzzyScore(query: string, target: string): number {
|
||||
|
||||
/**
|
||||
* Clear all history for a specific host, or all history if no hostId given.
|
||||
* A global clear also bumps the shared clear generation so other renderers and
|
||||
* in-flight histfile imports can detect it.
|
||||
*/
|
||||
export function clearHistory(hostId?: string): void {
|
||||
const store = loadStore();
|
||||
@@ -434,6 +570,12 @@ export function clearHistory(hostId?: string): void {
|
||||
store.entries = store.entries.filter((e) => e.hostId !== hostId);
|
||||
} else {
|
||||
store.entries = [];
|
||||
store.clearGeneration = readClearGeneration(store) + 1;
|
||||
}
|
||||
saveStore(store);
|
||||
// Destructive clears must be durable: cancel any pending debounced write and
|
||||
// persist synchronously so a later stale flush cannot restore the entries.
|
||||
cancelPendingSave();
|
||||
persistStoreNow(store);
|
||||
knownClearGeneration = readClearGeneration(store);
|
||||
if (!hostId) notifyCommandHistoryCleared();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } 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([]);
|
||||
});
|
||||
|
||||
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'echo a');
|
||||
recordComposeBarHistory('b', 'echo b');
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
clearComposeBarHistory();
|
||||
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
assert.deepEqual(getComposeBarHistory('b'), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearComposeBarHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
|
||||
|
||||
// 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[] }>();
|
||||
|
||||
type Listener = () => void;
|
||||
const clearedListeners = new Set<Listener>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to full compose-bar history clears. A live compose bar uses this to
|
||||
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
|
||||
*/
|
||||
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => {
|
||||
clearedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-memory compose-bar history for every session.
|
||||
*
|
||||
* This store is session-only (never persisted), so the "clear all command
|
||||
* history" action must clear it explicitly. Otherwise a command sent through
|
||||
* the compose bar stays recallable, and resending it would re-record it into
|
||||
* shell history and autocomplete after the user deleted it.
|
||||
*/
|
||||
export function clearComposeBarHistory(): void {
|
||||
histories.clear();
|
||||
// Always notify: a live compose bar can still hold a cached walk even when
|
||||
// the map is already empty (for example after its session was pruned).
|
||||
for (const listener of [...clearedListeners]) listener();
|
||||
}
|
||||
|
||||
// The compose bar holds the most recent commands sent through it, so clearing
|
||||
// command history must drop it too. Subscribing to the command-history clear
|
||||
// event covers every renderer: a detached terminal popup renders its own
|
||||
// TerminalComposeBar and learns about a main-window clear through the shared
|
||||
// clear generation, while the main window is notified synchronously.
|
||||
subscribeCommandHistoryCleared(clearComposeBarHistory);
|
||||
@@ -0,0 +1,109 @@
|
||||
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 { editorTabStore } from "../../application/state/editorTabStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { toast } from "../../components/ui/toast.tsx";
|
||||
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
|
||||
|
||||
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
|
||||
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const original = {
|
||||
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(original, "window");
|
||||
const before = editorTabStore.getTab(original.editorId);
|
||||
t.after(() => editorTabStore.close(original.editorId));
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: async () => { focusCount++; return { success: true }; },
|
||||
}));
|
||||
const errors = t.mock.method(toast, "error", () => {});
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
|
||||
});
|
||||
const modal = { ...original, content: "modal edits" };
|
||||
await act(async () => { ops[action](modal); });
|
||||
assert.equal(ops.showTextEditor, true);
|
||||
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
|
||||
assert.equal(ops.textEditorContent, "original");
|
||||
assert.equal(modal.content, "modal edits");
|
||||
assert.equal(editorTabStore.getTab(original.editorId), before);
|
||||
assert.equal(focusCount, 0);
|
||||
assert.equal(errors.mock.callCount(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const focusSucceeds of [true, false]) {
|
||||
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let ops!: UseSftpViewFileOpsResult;
|
||||
const params = {
|
||||
sftpRef: { current: {
|
||||
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
|
||||
readTextFile: async () => "original",
|
||||
} },
|
||||
behaviorRef: { current: "" }, autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
|
||||
t: (key: string) => key,
|
||||
} as unknown as UseSftpViewFileOpsParams;
|
||||
const detached = {
|
||||
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
|
||||
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
|
||||
};
|
||||
editorTabStore.upsertFromSnapshot(detached, "window");
|
||||
const before = editorTabStore.getTab(detached.editorId);
|
||||
t.after(() => editorTabStore.close(detached.editorId));
|
||||
let finishFocus!: (result: { success: boolean }) => void;
|
||||
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
|
||||
let focusCount = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
focusEditorWindow: () => { focusCount++; return focusing; },
|
||||
}));
|
||||
function Harness() { ops = useSftpViewFileOps(params); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
t.after(async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
});
|
||||
await act(async () => {
|
||||
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
|
||||
});
|
||||
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
|
||||
assert.equal(focusCount, 1);
|
||||
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
|
||||
await act(async () => { finishFocus({ success: focusSucceeds }); });
|
||||
assert.equal(ops.showTextEditor, !focusSucceeds);
|
||||
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
|
||||
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
|
||||
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { findEditorSftpOwnerTabId, registerEditorSftpOwnerResolver } from "./editorSftpOwnerRegistry";
|
||||
|
||||
test("resolves the owning tab for a registered SFTP connection id", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["left-abc", "right-def"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("left-abc"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("right-def"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("left-unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the stable pane tab id when the connection id is stale", () => {
|
||||
// Browse reconnects regenerate connection ids; an unsaved editor may still
|
||||
// reference the pre-reconnect id, so owner resolution must also match on the
|
||||
// stable pane tab id.
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn_new"],
|
||||
paneTabIds: ["pane_1"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("unregistering a resolver stops it from resolving owners", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-x"],
|
||||
ownerTabId: "tab-x",
|
||||
}));
|
||||
unregister();
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-x"), null);
|
||||
});
|
||||
|
||||
test("resolvers without an owner tab id never match", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-y"],
|
||||
ownerTabId: null,
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-y"), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Maps live SFTP connection ids to the top-level tab that owns the panel
|
||||
* hosting them (a terminal/workspace tab for SftpSidePanel instances).
|
||||
*
|
||||
* Editor tabs record the SFTP `connection.id` as their `sessionId` — not the
|
||||
* owning terminal session id — so batch-close logic that wants to keep an
|
||||
* editor's owning tab open after a cancelled dirty-save prompt must resolve
|
||||
* the owner through this registry instead of comparing `editorTab.sessionId`
|
||||
* against terminal session ids.
|
||||
*
|
||||
* Each mounted SftpSidePanel registers a resolver that reports the connection
|
||||
* ids it currently owns plus its host tab id; the top-level SftpView either
|
||||
* registers nothing (its owner is not a closable terminal tab) or registers
|
||||
* with a non-terminal owner id that simply never matches.
|
||||
*/
|
||||
export interface EditorSftpOwnerSnapshot {
|
||||
connectionIds: readonly string[];
|
||||
/**
|
||||
* Stable SFTP pane tab ids (pane.id) hosted by this panel. Unlike connection
|
||||
* ids these survive browse reconnects that regenerate connection ids, so
|
||||
* owner resolution can fall back to them when an editor still references a
|
||||
* pre-reconnect connection id.
|
||||
*/
|
||||
paneTabIds?: readonly string[];
|
||||
ownerTabId: string | null;
|
||||
}
|
||||
|
||||
type EditorSftpOwnerResolver = () => EditorSftpOwnerSnapshot;
|
||||
|
||||
const resolvers = new Set<EditorSftpOwnerResolver>();
|
||||
|
||||
export const registerEditorSftpOwnerResolver = (resolver: EditorSftpOwnerResolver): (() => void) => {
|
||||
resolvers.add(resolver);
|
||||
return () => {
|
||||
resolvers.delete(resolver);
|
||||
};
|
||||
};
|
||||
|
||||
export const findEditorSftpOwnerTabId = (
|
||||
connectionId: string | undefined,
|
||||
paneTabId?: string,
|
||||
): string | null => {
|
||||
if (!connectionId && !paneTabId) return null;
|
||||
for (const resolve of resolvers) {
|
||||
const snapshot = resolve();
|
||||
if (!snapshot.ownerTabId) continue;
|
||||
if (connectionId && snapshot.connectionIds.includes(connectionId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
if (paneTabId && snapshot.paneTabIds?.includes(paneTabId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
|
||||
t.mock.method(Date, "now", () => 1234567890);
|
||||
const [firstRenderer, secondRenderer] = await Promise.all([
|
||||
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
|
||||
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
|
||||
]);
|
||||
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
|
||||
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
|
||||
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
|
||||
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
|
||||
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
|
||||
});
|
||||
@@ -28,6 +28,20 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
|
||||
store._debugInsert(tab);
|
||||
const id = store.promoteFromModal({
|
||||
...makeTab(),
|
||||
remotePath: "/etc//nginx/./nginx.conf",
|
||||
});
|
||||
assert.equal(id, tab.id);
|
||||
assert.equal(store.getTab(id), tab);
|
||||
assert.equal(store.isDirty(id), true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
@@ -23,6 +24,7 @@ export interface EditorTab {
|
||||
sftpTabId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
@@ -32,12 +34,16 @@ export interface EditorTab {
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
placement?: EditorTabPlacement;
|
||||
windowDirty?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
|
||||
|
||||
export const tabIsDirty = (tab: EditorTab): boolean =>
|
||||
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
@@ -79,7 +85,82 @@ export class EditorTabStore {
|
||||
};
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
return !!t && tabIsDirty(t);
|
||||
};
|
||||
|
||||
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
|
||||
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
|
||||
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
|
||||
this.patch(id, {
|
||||
placement: "window",
|
||||
windowDirty: dirty,
|
||||
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
|
||||
});
|
||||
this.notifyStructural();
|
||||
};
|
||||
|
||||
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
|
||||
const tab = this.getTab(id);
|
||||
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
|
||||
this.patch(id, { windowDirty: dirty });
|
||||
};
|
||||
|
||||
upsertFromSnapshot = (
|
||||
snapshot: EditorWindowTabSnapshot,
|
||||
placement: EditorTabPlacement = "tab",
|
||||
): EditorTabId => {
|
||||
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = existingById ?? this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
const next: Partial<EditorTab> = {
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: placement === "window" ? "" : snapshot.content,
|
||||
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
|
||||
placement,
|
||||
windowDirty: placement === "window"
|
||||
? snapshot.content !== snapshot.baselineContent
|
||||
: false,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
if (existing) {
|
||||
this.patch(existing.id, next);
|
||||
if (existing.placement !== placement) this.notifyStructural();
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: snapshot.editorId || this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
sftpTabId: snapshot.sftpTabId,
|
||||
hostId: snapshot.hostId,
|
||||
hostLabel: snapshot.hostLabel,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: next.content ?? "",
|
||||
baselineContent: next.baselineContent ?? "",
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: (next.viewState ?? null) as EditorTab["viewState"],
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement,
|
||||
windowDirty: next.windowDirty === true,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
@@ -91,6 +172,12 @@ export class EditorTabStore {
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
const tab = this.getTab(id);
|
||||
if (tab?.placement === "window") {
|
||||
// Only the detached renderer has the current content. Wait for its dirty report.
|
||||
this.patch(id, { savingState: "idle", saveError: null });
|
||||
return;
|
||||
}
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
@@ -174,17 +261,26 @@ export class EditorTabStore {
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
placement?: EditorTabPlacement;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.placement === "window") {
|
||||
if (snapshot.content !== snapshot.baselineContent) {
|
||||
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
placement: snapshot.placement ?? existing.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
@@ -204,6 +300,8 @@ export class EditorTabStore {
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
placement: snapshot.placement ?? "tab",
|
||||
windowDirty: false,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notifyStructural();
|
||||
@@ -221,9 +319,9 @@ export class EditorTabStore {
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
|
||||
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
const dirty = tabIsDirty(tab);
|
||||
if (!dirty) {
|
||||
onCloseTab?.(tab.id);
|
||||
this.close(tab.id);
|
||||
@@ -332,9 +430,11 @@ export type EditorTabChrome = Pick<
|
||||
| 'sessionId'
|
||||
| 'sftpTabId'
|
||||
| 'hostId'
|
||||
| 'hostLabel'
|
||||
| 'remotePath'
|
||||
| 'fileName'
|
||||
| 'languageId'
|
||||
| 'placement'
|
||||
>;
|
||||
|
||||
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
@@ -343,9 +443,11 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
placement: tab.placement ?? "tab",
|
||||
});
|
||||
|
||||
export const useHasEditorTabForSessions = (
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
import { editorTabStore } from "./editorTabStore.ts";
|
||||
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
|
||||
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
|
||||
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
|
||||
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
|
||||
|
||||
const snapshot = {
|
||||
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
|
||||
remotePath: "/file", fileName: "file", languageId: "plaintext",
|
||||
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
|
||||
};
|
||||
|
||||
for (const latestContent of ["v3", "v2", "v1"]) {
|
||||
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let finish!: (result: { ok: boolean }) => void;
|
||||
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
|
||||
const reports: boolean[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
saveEditorWindowTab: (request: { content: string }) => {
|
||||
assert.equal(request.content, "v2");
|
||||
return pending;
|
||||
},
|
||||
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
|
||||
}));
|
||||
const saving = saveDetachedEditorTab(snapshot.editorId);
|
||||
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
|
||||
finish({ ok: true });
|
||||
assert.deepEqual(await saving, { ok: true });
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
|
||||
assert.deepEqual(reports, [latestContent !== "v2"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot, "window");
|
||||
t.after(() => {
|
||||
editorTabStore.close(snapshot.editorId);
|
||||
releaseEditorTabSaveCoordinator(snapshot.editorId);
|
||||
});
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { finish = resolve; });
|
||||
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
|
||||
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
|
||||
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
|
||||
const receipts: EditorWindowSaveResult[] = [];
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
|
||||
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
|
||||
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
|
||||
}));
|
||||
t.after(installEditorWindowSourceListeners());
|
||||
const saving = onSave({ ...snapshot, requestId: "request" });
|
||||
onDirty({ editorId: snapshot.editorId, dirty: true });
|
||||
finish();
|
||||
await saving;
|
||||
assert.equal(receipts[0]?.ok, true);
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
|
||||
onDirty({ editorId: snapshot.editorId, dirty: false });
|
||||
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
|
||||
});
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
|
||||
const opening = popOutEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted" });
|
||||
assert.equal(await opening, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
|
||||
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
|
||||
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["accepted", "rejected", "failed"] as const) {
|
||||
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let resolve!: (result: { success: boolean; error?: string }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
dockEditorWindowTab: (sent: typeof snapshot) => {
|
||||
calls++;
|
||||
assert.equal(sent.content, "v2");
|
||||
return receipt;
|
||||
},
|
||||
}));
|
||||
const docking = dockDetachedEditorTab(snapshot.editorId);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 1);
|
||||
if (outcome === "failed") reject(new Error("IPC disconnected"));
|
||||
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
|
||||
assert.equal((await docking).success, outcome === "accepted");
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
|
||||
if (outcome !== "accepted") {
|
||||
assert.equal(tab.saveError, "previous save failed");
|
||||
editorTabStore.updateContent(snapshot.editorId, "v3", null);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("dock does not take over an in-progress save", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
editorTabStore.setSavingState(snapshot.editorId, "saving");
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
|
||||
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
|
||||
});
|
||||
|
||||
test("a closing destination rejects a popout without releasing source content", async (t) => {
|
||||
editorTabStore.upsertFromSnapshot(snapshot);
|
||||
t.after(() => editorTabStore.close(snapshot.editorId));
|
||||
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
|
||||
assert.equal(await popOutEditorTab(snapshot.editorId), false);
|
||||
const tab = editorTabStore.getTab(snapshot.editorId)!;
|
||||
assert.equal(tab.placement, "tab");
|
||||
assert.equal(tab.content, "v2");
|
||||
assert.equal(tab.baselineContent, "v1");
|
||||
assert.equal(editorTabStore.isDirty(tab.id), true);
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { saveEditorTab } from "./editorTabSave";
|
||||
import {
|
||||
editorTabStore,
|
||||
tabIsDirty,
|
||||
type EditorTab,
|
||||
type EditorTabId,
|
||||
} from "./editorTabStore";
|
||||
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
|
||||
import { activeTabStore, toEditorTabId } from "./activeTabStore";
|
||||
|
||||
export function toEditorWindowSnapshot(
|
||||
tab: EditorTab,
|
||||
hostLabel?: string,
|
||||
): EditorWindowTabSnapshot {
|
||||
return {
|
||||
editorId: tab.id,
|
||||
sessionId: tab.sessionId,
|
||||
sftpTabId: tab.sftpTabId,
|
||||
hostId: tab.hostId,
|
||||
hostLabel: hostLabel || tab.hostLabel,
|
||||
remotePath: tab.remotePath,
|
||||
fileName: tab.fileName,
|
||||
languageId: tab.languageId,
|
||||
content: tab.content,
|
||||
baselineContent: tab.baselineContent,
|
||||
wordWrap: tab.wordWrap,
|
||||
viewState: tab.viewState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openEditorWindow) return false;
|
||||
const result = await bridge.openEditorWindow(snapshot);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
|
||||
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function popOutEditorTab(
|
||||
tabId: EditorTabId,
|
||||
hostLabel?: string,
|
||||
): Promise<boolean> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return false;
|
||||
if (tab.placement === "window") {
|
||||
return focusEditorInWindow(tabId);
|
||||
}
|
||||
const dirty = tabIsDirty(tab);
|
||||
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
|
||||
// Hide the source editor during transfer, but retain its only owned copy
|
||||
// until the destination has acknowledged installing the tab.
|
||||
editorTabStore.markDetached(tabId, dirty, false);
|
||||
const ok = await openEditorInWindow(snapshot).catch(() => false);
|
||||
if (!ok) {
|
||||
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
|
||||
return false;
|
||||
}
|
||||
editorTabStore.markDetached(tabId, dirty);
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (activeId === toEditorTabId(tabId)) {
|
||||
activeTabStore.setActiveTabId("vault");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeOwnedEditorWindowTabs(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
options?: { force?: boolean },
|
||||
): Promise<{ cancelled: boolean; closedIds: string[] }> {
|
||||
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
|
||||
if (matching.length === 0) return { cancelled: false, closedIds: [] };
|
||||
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: options?.force === true,
|
||||
});
|
||||
const closedIds = result?.closedIds ?? [];
|
||||
for (const id of closedIds) editorTabStore.close(id);
|
||||
if (!result || result.success === false) {
|
||||
return { cancelled: options?.force !== true, closedIds };
|
||||
}
|
||||
if (result.cancelled !== true) {
|
||||
for (const tab of matching) editorTabStore.close(tab.id);
|
||||
}
|
||||
return {
|
||||
cancelled: result.cancelled === true,
|
||||
closedIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmCloseOwnedEditors(
|
||||
owner: { sessionId?: string; sftpTabId?: string },
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> {
|
||||
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
|
||||
if (windowClose.cancelled) return false;
|
||||
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
|
||||
}
|
||||
|
||||
export function forceCloseOwnedEditors(owners: {
|
||||
sessionIds?: readonly string[];
|
||||
sftpTabIds?: readonly string[];
|
||||
}): EditorTabId[] {
|
||||
const matching = editorTabStore.getTabs().filter((tab) => {
|
||||
if (tab.placement !== "window") return false;
|
||||
if (owners.sessionIds?.includes(tab.sessionId)) return true;
|
||||
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
|
||||
return false;
|
||||
});
|
||||
if (matching.length > 0) {
|
||||
void netcattyBridge.get()?.closeEditorWindowTabs?.({
|
||||
editorIds: matching.map((tab) => tab.id),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
return editorTabStore.forceCloseByOwners(owners);
|
||||
}
|
||||
|
||||
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
|
||||
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
|
||||
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
|
||||
}
|
||||
|
||||
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
|
||||
editorId: current.id,
|
||||
sessionId: current.sessionId,
|
||||
sftpTabId: current.sftpTabId,
|
||||
hostId: current.hostId,
|
||||
remotePath: current.remotePath,
|
||||
content: current.content,
|
||||
});
|
||||
if (result?.ok) {
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({
|
||||
editorId: tabId,
|
||||
dirty: editorTabStore.isDirty(tabId),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
const error = result?.error || "Save failed";
|
||||
editorTabStore.setSavingState(tabId, "error", error);
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
|
||||
const tab = editorTabStore.getTab(tabId);
|
||||
if (!tab) return { success: false, error: "Editor tab closed" };
|
||||
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
|
||||
// Keep the snapshot's owner read-only until receipt. Success stays busy
|
||||
// until the caller removes this copy; failure restores its previous state.
|
||||
editorTabStore.setSavingState(tabId, "saving");
|
||||
let accepted = false;
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
|
||||
toEditorWindowSnapshot(tab, tab.hostLabel),
|
||||
);
|
||||
if (result?.success) {
|
||||
accepted = true;
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result?.error || "Failed to dock editor tab" };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
|
||||
} finally {
|
||||
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
|
||||
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
|
||||
}
|
||||
|
||||
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
|
||||
if (editorIds.length === 0) return;
|
||||
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowCloseTabs(
|
||||
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
|
||||
): () => void {
|
||||
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
|
||||
}
|
||||
|
||||
export function reportEditorWindowCloseTabsResult(
|
||||
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
|
||||
): void {
|
||||
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
|
||||
}
|
||||
|
||||
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return () => {};
|
||||
return bridge.onCheckDirtyEditors(() => {
|
||||
let hasDirty = false;
|
||||
try {
|
||||
hasDirty = cb() === true;
|
||||
} catch {
|
||||
hasDirty = false;
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installEditorWindowSourceListeners(): () => void {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) return () => {};
|
||||
|
||||
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
|
||||
try {
|
||||
const tab = editorTabStore.getTab(payload.editorId);
|
||||
if (!tab) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: "Editor tab closed before save completed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tab.sessionId !== payload.sessionId) {
|
||||
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
|
||||
}
|
||||
const ok = await saveEditorTab(payload.editorId, payload.content);
|
||||
const latest = editorTabStore.getTab(payload.editorId);
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok,
|
||||
liveConnectionId: latest?.sessionId,
|
||||
error: ok ? undefined : (latest?.saveError || "Save failed"),
|
||||
});
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowSaveResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Save failed",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
|
||||
try {
|
||||
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
|
||||
activeTabStore.setActiveTabId(toEditorTabId(id));
|
||||
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
|
||||
} catch (err) {
|
||||
bridge.reportEditorWindowDockResult?.({
|
||||
requestId: payload.requestId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to dock",
|
||||
});
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
|
||||
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
|
||||
}) ?? (() => {});
|
||||
|
||||
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
|
||||
for (const editorId of payload.editorIds ?? []) {
|
||||
editorTabStore.close(editorId);
|
||||
}
|
||||
}) ?? (() => {});
|
||||
|
||||
return () => {
|
||||
unsubSave();
|
||||
unsubDock();
|
||||
unsubDirty();
|
||||
unsubClosed();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type EditorTabPlacement = "tab" | "window";
|
||||
|
||||
export interface EditorWindowTabSnapshot {
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
hostLabel?: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: unknown;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveRequest {
|
||||
requestId: string;
|
||||
editorId: string;
|
||||
sessionId: string;
|
||||
sftpTabId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowSaveResult {
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
liveConnectionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsRequest {
|
||||
requestId: string;
|
||||
editorIds: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorWindowCloseTabsResult {
|
||||
requestId: string;
|
||||
cancelled: boolean;
|
||||
closedIds: string[];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
|
||||
|
||||
test('detached terminal requests reach the main window instead of being dropped', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const forwarded: HostBrowserPanelRequest[] = [];
|
||||
const delivered: HostBrowserPanelRequest[] = [];
|
||||
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
|
||||
let removed = false;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
|
||||
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
|
||||
nativeListener = listener;
|
||||
return () => { removed = true; nativeListener = undefined; };
|
||||
},
|
||||
} } });
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
|
||||
requestHostBrowserPanel(request);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(forwarded, [request]);
|
||||
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
|
||||
assert.ok(nativeListener);
|
||||
nativeListener(request);
|
||||
assert.deepEqual(delivered, [request]);
|
||||
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
|
||||
unsubscribe(); unsubscribe = undefined;
|
||||
assert.equal(removed, true);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
|
||||
type Listener = (request: HostBrowserPanelRequest) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: HostBrowserPanelRequest | null = null;
|
||||
let unsubscribeNative: (() => void) | undefined;
|
||||
|
||||
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
|
||||
if (!listeners.size) {
|
||||
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
|
||||
if (forward) {
|
||||
void forward(request).catch(() => { pending = request; });
|
||||
} else pending = request;
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) listener(request);
|
||||
}
|
||||
|
||||
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
|
||||
for (const current of listeners) current(request);
|
||||
});
|
||||
if (pending) {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
listener(request);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getPendingMultilinePasteConfirm,
|
||||
requestMultilinePasteConfirm,
|
||||
respondMultilinePasteConfirm,
|
||||
subscribeMultilinePasteConfirm,
|
||||
} from "./multilinePasteConfirmStore";
|
||||
|
||||
test("request stores the pending paste and respond resolves it", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.lineCount, 2);
|
||||
|
||||
respondMultilinePasteConfirm("line-by-line", "line1\nline2\nline3");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "line-by-line", text: "line1\nline2\nline3" });
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
});
|
||||
|
||||
test("respond without text falls back to the original request text", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
respondMultilinePasteConfirm("send");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "send", text: "line1\nline2" });
|
||||
});
|
||||
|
||||
test("a newer paste request supersedes the pending one as cancelled", async () => {
|
||||
const first = requestMultilinePasteConfirm({ text: "a\nb", lineCount: 2, charCount: 3 });
|
||||
const second = requestMultilinePasteConfirm({ text: "c\nd", lineCount: 2, charCount: 3 });
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.text, "c\nd");
|
||||
|
||||
respondMultilinePasteConfirm("send", "c\nd");
|
||||
assert.deepEqual(await first, { action: "cancel", text: "a\nb" });
|
||||
assert.deepEqual(await second, { action: "send", text: "c\nd" });
|
||||
});
|
||||
|
||||
test("respond without a pending request is a no-op and listeners are notified", async () => {
|
||||
respondMultilinePasteConfirm("send");
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
|
||||
const events: Array<string | null> = [];
|
||||
const unsubscribe = subscribeMultilinePasteConfirm(() => {
|
||||
events.push(getPendingMultilinePasteConfirm()?.id ?? "cleared");
|
||||
});
|
||||
const pending = requestMultilinePasteConfirm({ text: "x", lineCount: 1, charCount: 1 });
|
||||
const pendingId = getPendingMultilinePasteConfirm()?.id ?? null;
|
||||
respondMultilinePasteConfirm("cancel", "x");
|
||||
await pending;
|
||||
unsubscribe();
|
||||
|
||||
assert.ok(pendingId);
|
||||
assert.deepEqual(events, [pendingId, "cleared"]);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* App-level store for the "paste multiple lines" confirmation dialog (#3398).
|
||||
*
|
||||
* Terminal paste handlers call `requestMultilinePasteConfirm` from anywhere in
|
||||
* the renderer and await the user's decision; `MultilinePasteConfirmHost`
|
||||
* (mounted once in App) renders the dialog and resolves the pending request.
|
||||
*/
|
||||
|
||||
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
|
||||
|
||||
export interface MultilinePasteConfirmRequest {
|
||||
id: string;
|
||||
/** Clipboard text the dialog previews (editable by the user). */
|
||||
text: string;
|
||||
lineCount: number;
|
||||
charCount: number;
|
||||
/** Restore the originating terminal's focus after the modal releases it. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface MultilinePasteConfirmResponse {
|
||||
action: MultilinePasteConfirmAction;
|
||||
/** Text to send: the preview content, possibly edited by the user. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
request: MultilinePasteConfirmRequest;
|
||||
resolve: (response: MultilinePasteConfirmResponse) => void;
|
||||
}
|
||||
|
||||
let pending: PendingRequest | null = null;
|
||||
let nextRequestId = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export function requestMultilinePasteConfirm(
|
||||
request: Omit<MultilinePasteConfirmRequest, "id">,
|
||||
): Promise<MultilinePasteConfirmResponse> {
|
||||
// Only one confirmation dialog can be pending; a newer paste supersedes the
|
||||
// older one (the stale promise resolves as "cancel" so its paste is dropped).
|
||||
if (pending) {
|
||||
const stale = pending;
|
||||
pending = null;
|
||||
stale.resolve({ action: "cancel", text: stale.request.text });
|
||||
}
|
||||
nextRequestId += 1;
|
||||
const requestWithId: MultilinePasteConfirmRequest = {
|
||||
...request,
|
||||
id: `multiline-paste-${nextRequestId}`,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
pending = { request: requestWithId, resolve };
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function respondMultilinePasteConfirm(
|
||||
action: MultilinePasteConfirmAction,
|
||||
text?: string,
|
||||
): void {
|
||||
const current = pending;
|
||||
if (!current) return;
|
||||
pending = null;
|
||||
current.resolve({ action, text: text ?? current.request.text });
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Snapshot for `useSyncExternalStore`; stable reference while pending. */
|
||||
export const getPendingMultilinePasteConfirm =
|
||||
(): MultilinePasteConfirmRequest | null => pending?.request ?? null;
|
||||
|
||||
export function subscribeMultilinePasteConfirm(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -2,15 +2,26 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatSessionClosedExitMessage,
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
test("backend exited events close the session tab only when auto-close is enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
|
||||
// The fallback for callers that have not loaded the terminal setting yet:
|
||||
// keep the tab so a jump host / bastion reaping a session (a clean exit)
|
||||
// never silently deletes the user's scrollback.
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -56,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative exit-signal disconnects keep the tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
|
||||
});
|
||||
|
||||
test("disabled auto-close keeps command and attached terminal popups open", () => {
|
||||
@@ -120,3 +139,65 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages show the reported code for a real remote exit", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
|
||||
"\r\n[session closed (code 0)]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
|
||||
"\r\n[session closed (code 127)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "closed",
|
||||
remoteClosedWithoutExitStatus: true,
|
||||
}),
|
||||
"\r\n[session closed by remote (no exit status)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed" }),
|
||||
"\r\n[session closed]",
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,10 +4,31 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
|
||||
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
|
||||
// terminated the session process. Distinct from a transport "closed".
|
||||
exitSignal?: string;
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
remoteClosedWithoutExitStatus?: boolean;
|
||||
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
@@ -23,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
autoCloseOnExit = true,
|
||||
// Keep-tab is the safe default: a caller that has not loaded the terminal
|
||||
// setting yet must not silently delete a session tab. Remote graceful
|
||||
// channel closes (e.g. a jump host reaping an idle session) are reported
|
||||
// as a clean exit, so an unavailable setting must not re-enable closing.
|
||||
autoCloseOnExit = false,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runAutomationScript,
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
function installPermissionMode(mode: string) {
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const writeScript: Snippet = {
|
||||
id: 'write-script',
|
||||
label: 'Write',
|
||||
command: "await nct.screen.sendLine('echo hi');",
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'user-run', runIds: ['user-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
const result = await runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-user',
|
||||
initiatedBy: 'user',
|
||||
});
|
||||
assert.equal(result.runId, 'user-run');
|
||||
assert.equal(seenMode, 'auto');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'automatic-run', runIds: ['automatic-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks an AI write script in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'ai-run', runIds: ['ai-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-ai',
|
||||
initiatedBy: 'ai',
|
||||
}),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'ai-read', runIds: ['ai-read'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet: {
|
||||
id: 'read-script',
|
||||
label: 'Read',
|
||||
command: "await nct.screen.waitForPrompt(1000);",
|
||||
kind: 'script',
|
||||
},
|
||||
sessionId: 'sess-ai-read',
|
||||
initiatedBy: 'ai',
|
||||
});
|
||||
assert.equal(seenMode, 'observer');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
|
||||
initiatedBy?: 'user' | 'ai';
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createHostTerminalSession } from "./sessionFactories.ts";
|
||||
import { createLocalTerminalSession } from "./sessionFactories.ts";
|
||||
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
|
||||
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "local-vault-1",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
group: "Projects/A",
|
||||
tags: ["local"],
|
||||
os: "linux",
|
||||
protocol: "local",
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
|
||||
const host = localVaultHost({
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/work"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localShellIcon: "ubuntu",
|
||||
localStartDir: "/work",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", host);
|
||||
|
||||
assert.equal(session.protocol, "local");
|
||||
assert.equal(session.hostId, "local-vault-1");
|
||||
assert.equal(session.localShell, "wsl-ubuntu");
|
||||
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
|
||||
assert.equal(session.localShellName, "Ubuntu (WSL)");
|
||||
assert.equal(session.localShellIcon, "ubuntu");
|
||||
assert.equal(session.localStartDir, "/work");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
|
||||
const host: Host = {
|
||||
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
|
||||
} as Host;
|
||||
const session = createHostTerminalSession("session-2", host);
|
||||
|
||||
assert.equal(session.protocol, "ssh");
|
||||
assert.equal(session.localShell, undefined);
|
||||
assert.equal(session.localShellName, undefined);
|
||||
assert.equal(session.shellType, undefined);
|
||||
});
|
||||
|
||||
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
|
||||
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
|
||||
assert.equal(cmd.shellType, "cmd");
|
||||
|
||||
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
|
||||
assert.equal(pwsh.shellType, "powershell");
|
||||
|
||||
// Discovered shell IDs persisted by the local-shell editor are not
|
||||
// executable basenames, but they still resolve to POSIX shells at spawn
|
||||
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
|
||||
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
|
||||
assert.equal(wslId.shellType, "posix");
|
||||
|
||||
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
|
||||
assert.equal(gitBash.shellType, "posix");
|
||||
|
||||
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
|
||||
assert.equal(cygwin.shellType, "posix");
|
||||
|
||||
// Unix discovery emits disambiguated path-slug IDs when multiple
|
||||
// installations share a basename (e.g. /bin/bash vs
|
||||
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
|
||||
// the session must classify as that command's shell type, not "unknown".
|
||||
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
|
||||
assert.equal(slugBash.shellType, "posix");
|
||||
|
||||
const slugHomebrewBash = createHostTerminalSession(
|
||||
"session-slug-homebrew",
|
||||
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
|
||||
);
|
||||
assert.equal(slugHomebrewBash.shellType, "posix");
|
||||
|
||||
const customPowershellPath = createHostTerminalSession(
|
||||
"session-pwsh-path",
|
||||
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
|
||||
);
|
||||
assert.equal(customPowershellPath.shellType, "powershell");
|
||||
|
||||
// Default-shell hosts (empty localShell) launch the machine's OS default
|
||||
// shell (the backend's own detection), so classification must use the
|
||||
// runtime platform — not the saved host.os, which may describe a machine
|
||||
// the host was synced/imported from. Node 22's navigator.userAgent is
|
||||
// always "Node.js/<version>", so this test environment always classifies
|
||||
// as POSIX regardless of the CI runner's real OS.
|
||||
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
|
||||
assert.equal(windowsSyncedDefault.shellType, "posix");
|
||||
|
||||
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
|
||||
assert.equal(posixDefault.shellType, "posix");
|
||||
|
||||
const unknownCustom = createHostTerminalSession(
|
||||
"session-unknown",
|
||||
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
|
||||
);
|
||||
assert.equal(unknownCustom.shellType, "unknown");
|
||||
});
|
||||
|
||||
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
|
||||
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
|
||||
assert.equal(session.hostId, "local-terminal");
|
||||
assert.equal(session.localShellName, "CMD");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
|
||||
// Legacy vault hosts can predate localShellArgs, so the field may be
|
||||
// absent on the Host; the session snapshot must still distinguish
|
||||
// "launched with no args" ([]) from "no metadata" (undefined).
|
||||
const host = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-noargs", host);
|
||||
|
||||
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
|
||||
assert.deepEqual(session.localShellArgs, []);
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
|
||||
const launchTimeHost = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", launchTimeHost);
|
||||
|
||||
// The saved host was later edited to a shell with args; the snapshot taken
|
||||
// at launch time must win so the clone relaunches CMD, not WSL.
|
||||
const editedHost: Host = {
|
||||
...launchTimeHost,
|
||||
label: "Edited WSL",
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/elsewhere"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localStartDir: "/elsewhere",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [editedHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
|
||||
// The saved host has no directory of its own, so the starter launches in
|
||||
// Settings > Terminal's localStartDir. That effective directory has to be
|
||||
// recorded now; overlay falls back to the host or the current setting when
|
||||
// the session field is missing.
|
||||
const saved = localVaultHost({
|
||||
localShell: "bash",
|
||||
localShellName: "Bash",
|
||||
localStartDir: "",
|
||||
});
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
const session = createHostTerminalSession("session-inherit", effective);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
|
||||
const changedSettings = {
|
||||
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
|
||||
};
|
||||
const afterSettingsChange = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [saved],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
|
||||
|
||||
const afterHostEdit = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
|
||||
const saved = localVaultHost();
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
|
||||
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
|
||||
});
|
||||
const session = createHostTerminalSession("session-default-shell", effective);
|
||||
|
||||
assert.equal(session.localShell, "/bin/bash");
|
||||
assert.deepEqual(session.localShellArgs, ["-l"]);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
|
||||
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
|
||||
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedExplicit.localStartDir, "/work");
|
||||
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
|
||||
|
||||
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
|
||||
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedSsh.localStartDir, undefined);
|
||||
assert.equal(resolvedSsh, ssh);
|
||||
});
|
||||
|
||||
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
|
||||
const saved = localVaultHost({ username: "" });
|
||||
const ordinary = createLocalTerminalSession("ordinary-local");
|
||||
const opened = createHostTerminalSession("saved-local", saved);
|
||||
assert.equal(opened.username, ordinary.username);
|
||||
assert.equal(opened.username, "local");
|
||||
assert.equal(saved.username, "");
|
||||
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
|
||||
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
|
||||
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
|
||||
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -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",
|
||||
@@ -149,3 +171,16 @@ test("host session factories snapshot plugin connection configuration", () => {
|
||||
assert.deepEqual(workspace.pluginConnection, pluginConnection);
|
||||
assert.notEqual(workspace.pluginConnection, pluginConnection);
|
||||
});
|
||||
|
||||
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
|
||||
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
|
||||
const source = createHostTerminalSession("original-bash", original);
|
||||
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
|
||||
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
|
||||
assert.equal(copied.localShell, "/bin/bash");
|
||||
assert.equal(copied.localShellName, "");
|
||||
assert.equal(copied.localShellIcon, "");
|
||||
const named = createHostTerminalSession("named-zsh", edited);
|
||||
assert.equal(named.localShellName, "Zsh");
|
||||
assert.equal(named.localShellIcon, "zsh");
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -18,6 +19,28 @@ export interface LocalTerminalOptions {
|
||||
*/
|
||||
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
|
||||
|
||||
/**
|
||||
* [] captures the absence of custom arguments instead of inheriting them
|
||||
* from a later host edit. The starter still applies backend defaults to []
|
||||
* and undefined alike. Share the array to preserve pane equality checks.
|
||||
*/
|
||||
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
|
||||
|
||||
/**
|
||||
* Platform string describing the machine that is launching the session.
|
||||
*
|
||||
* Vault local hosts can be synced/imported from another OS, so their saved
|
||||
* `host.os` describes the source machine. When the host has no explicit
|
||||
* shell (`localShell` empty), `startLocalSession` spawns the current
|
||||
* machine's OS default shell, so classification must fall back to the
|
||||
* runtime platform — not the persisted host OS — or a Windows host opened
|
||||
* on Linux would be tagged "powershell" while Bash actually runs.
|
||||
*/
|
||||
const runtimePlatformLike = (): string | undefined => {
|
||||
if (typeof navigator === "undefined") return undefined;
|
||||
return navigator.userAgent || navigator.platform || undefined;
|
||||
};
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
sessionId: string,
|
||||
options?: LocalTerminalOptions,
|
||||
@@ -113,6 +136,30 @@ export const createHostTerminalSession = (
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
// Local-shell vault hosts carry the per-host shell selection into the
|
||||
// session (the bridge resolves discovered shell ids at spawn time).
|
||||
// shellType matches ordinary local-terminal creation so captured-CWD
|
||||
// restore can reject POSIX injection into CMD/PowerShell sessions.
|
||||
...(host.protocol === "local" ? {
|
||||
username: "local",
|
||||
// With an explicit shell the basename classifies it on any OS, so the
|
||||
// saved host.os stays the fallback; a default-shell host launches the
|
||||
// current machine's OS default, so classify against the runtime.
|
||||
shellType: classifyLocalShellType(
|
||||
host.localShell,
|
||||
host.localShell?.trim() ? host.os : runtimePlatformLike(),
|
||||
),
|
||||
localShell: host.localShell ?? "",
|
||||
// Preserve field presence: an edited host must not contribute its args
|
||||
// to a session that was launched with an explicit shell and no args.
|
||||
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
|
||||
localShellName: host.localShellName ?? "",
|
||||
localShellIcon: host.localShellIcon ?? "",
|
||||
// Effective directory from resolveHostDefaultLocalShell, including a
|
||||
// Settings directory the saved host inherits. Copy Tab overlays this
|
||||
// snapshot, so a later settings or vault edit cannot move the relaunch.
|
||||
localStartDir: host.localStartDir || "~",
|
||||
} : {}),
|
||||
...(host.ephemeral ? { ephemeralHost: true } : {}),
|
||||
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectSessionSearchFields,
|
||||
type SessionHistorySearchMessage,
|
||||
} from "../../domain/sessionHistorySearch.ts";
|
||||
import {
|
||||
PINYIN_CACHE_MAX_CHARS,
|
||||
getPinyinCacheStatsForTests,
|
||||
resetPinyinCacheForTests,
|
||||
} from "../../lib/searchMatcher.ts";
|
||||
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
|
||||
|
||||
// One instance for the whole file: it keeps the pre-factory semantics of every
|
||||
// test sharing (and clearing) a single cache.
|
||||
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
|
||||
|
||||
// Fixture messages stay loosely typed: persisted sessions carry far richer
|
||||
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
|
||||
// and extra properties must remain structurally harmless.
|
||||
function createSession(
|
||||
id: string,
|
||||
title: string,
|
||||
messages: Array<Record<string, unknown>>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
messages: messages.map((message, index) => ({
|
||||
id: `${id}-m${index}`,
|
||||
role: "user",
|
||||
content: "",
|
||||
timestamp: index,
|
||||
...message,
|
||||
})) as SessionHistorySearchMessage[],
|
||||
};
|
||||
}
|
||||
|
||||
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
|
||||
const sessions = [
|
||||
createSession("a", "nginx restart", []),
|
||||
createSession("b", "disk cleanup", []),
|
||||
];
|
||||
|
||||
for (const query of ["", " \t\n"]) {
|
||||
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
|
||||
}
|
||||
});
|
||||
|
||||
test("filterSessionHistory handles special characters through the shared matcher", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
|
||||
createSession("b", "Deploy", [{ content: "ship it" }]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches message content of user and assistant turns", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Debugging", [
|
||||
{ role: "user", content: "why is sshd down on prod-3?" },
|
||||
{ role: "assistant", content: "let me check the journal" },
|
||||
]),
|
||||
createSession("b", "Deploy notes", [
|
||||
{ role: "user", content: "ship release 1.2" },
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory ignores hidden system messages", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{ role: "system", content: "shared hidden prompt with marker text" },
|
||||
{ role: "user", content: "all clear" },
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{ role: "system", content: "shared hidden prompt with marker text" },
|
||||
{ role: "user", content: "ship it" },
|
||||
]),
|
||||
];
|
||||
|
||||
// A query found only inside system messages must not surface the sessions:
|
||||
// the reopened conversation hides those messages, so a match would display
|
||||
// no matching text.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory searches thinking and tool call/result content", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{ role: "assistant", thinking: "the certificate expired" },
|
||||
]),
|
||||
createSession("b", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
|
||||
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches text stored only in tool call arguments", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t1",
|
||||
arguments: { command: "systemctl restart nginx" },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted agent activities", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Research", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("b", "Refactor", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{
|
||||
id: "2",
|
||||
type: "file_change",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{ path: "src/sessionStore.ts", kind: "update" },
|
||||
{ path: "src/sessionStore.test.ts", kind: "add" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("c", "Plan", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{
|
||||
id: "3",
|
||||
type: "plan_update",
|
||||
status: "completed",
|
||||
items: [
|
||||
{ text: "extract search collector", completed: true },
|
||||
{ text: "wire into drawer", completed: false },
|
||||
],
|
||||
},
|
||||
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[2]);
|
||||
assert.ok(fields.some((field) => field.includes("extract search collector")));
|
||||
assert.ok(fields.some((field) => field.includes("rate limit hit")));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
|
||||
},
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "please summarize",
|
||||
attachments: [
|
||||
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
|
||||
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("c", "Legacy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "see attached",
|
||||
images: [
|
||||
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("d", "Payloads", [
|
||||
{
|
||||
role: "user",
|
||||
content: "",
|
||||
attachments: [
|
||||
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
|
||||
// Attachment base64 payloads and unlabeled attachments are not indexed.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[1]);
|
||||
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
|
||||
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
|
||||
assert.ok(!fields.some((field) => field === "AAAA"));
|
||||
});
|
||||
|
||||
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
|
||||
// One assistant message bearing enough tool calls (each argument capped at
|
||||
// 2,000 chars) to exhaust the remaining session budget: iteration must be
|
||||
// newest-first so the latest calls (appended at the array end) stay
|
||||
// searchable instead of the oldest ones winning the budget.
|
||||
const session = createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "y".repeat(20_000),
|
||||
toolCalls: Array.from({ length: 25 }, (_, i) => ({
|
||||
name: `call-${i}`,
|
||||
id: `t${i}`,
|
||||
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
// The newest call (index 24, appended last) stays searchable along with the
|
||||
// rest of the recent burst; the oldest call is the one dropped.
|
||||
assert.ok(fields.some((field) => field.includes("marker-args-24")));
|
||||
assert.ok(fields.some((field) => field.includes("marker-args-19")));
|
||||
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
|
||||
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
|
||||
// Retained call fields are output in chronological order after the
|
||||
// newest-first collection is reversed back (call-10 before call-19).
|
||||
const call10 = fields.findIndex((field) => field.includes("call-10"));
|
||||
const call19 = fields.findIndex((field) => field.includes("call-19"));
|
||||
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
|
||||
});
|
||||
|
||||
test("later attachment labels stay searchable when the label list overflows the cap", () => {
|
||||
// The joined labels of these attachments exceed the 2,000-character field
|
||||
// cap; feeding labels individually keeps the later (newest) ones findable.
|
||||
const session = createSession("a", "Deploy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "please summarize",
|
||||
attachments: [
|
||||
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
|
||||
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.ok(fields.some((field) => field.includes("latest-run.log")));
|
||||
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
|
||||
const session = createSession("a", " ", [
|
||||
{ role: "user", content: " " },
|
||||
{ role: "assistant", content: "x".repeat(25_000) },
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.equal(fields.length, 1);
|
||||
assert.equal(fields[0].length, 20_000);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
|
||||
const sessions = [
|
||||
createSession("a", "重启生产服务器", []),
|
||||
createSession("b", "backup", []),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
|
||||
// An empty persisted title is displayed as the localized "Untitled" label;
|
||||
// searching for that label must find the session instead of hiding it.
|
||||
const sessions = [
|
||||
createSession("a", "", [{ content: "nginx notes" }]),
|
||||
createSession("b", "nginx restart", []),
|
||||
];
|
||||
|
||||
// Without the fallback the blank title contributes nothing.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
|
||||
// Titled sessions are unaffected by the fallback label.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
|
||||
// Localized (CJK) fallback labels match through title pinyin too.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0], "Untitled");
|
||||
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
|
||||
const sessions = [
|
||||
createSession("a", "重启服务器", [
|
||||
{ role: "user", content: "restarted nginx without errors" },
|
||||
]),
|
||||
];
|
||||
|
||||
const combined = filterSessionHistory(sessions, "chongqi nginx");
|
||||
assert.deepEqual(combined, [sessions[0]]);
|
||||
// Message-only pinyin still stays out of the expensive fallback.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
||||
assert.deepEqual(
|
||||
filterSessionHistory(sessions, "failing nginx"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("search field trimming stays inside the field and remaining session limits", () => {
|
||||
const fieldLimited = createSession("a", "Ops", [
|
||||
{ content: " ".repeat(20_000) + "outside-field" },
|
||||
{ content: " recent-match " + " ".repeat(1_000_000) },
|
||||
]);
|
||||
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
|
||||
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
|
||||
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
|
||||
|
||||
// Title + three full fields leave 3,997 characters for the oldest message.
|
||||
// Trimming must not reach past that window to discover more content.
|
||||
const sessionLimited = createSession("b", "Ops", [
|
||||
{ content: " ".repeat(3_997) + "outside-session" },
|
||||
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
|
||||
]);
|
||||
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
|
||||
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{ role: "user", content: "重启生产服务器" },
|
||||
]),
|
||||
];
|
||||
|
||||
// Pinyin transliteration of long message content is skipped; literal CJK
|
||||
// queries still match message text.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields bounds the total searchable text per session", () => {
|
||||
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
|
||||
role: "user" as const,
|
||||
content: `chunk-${i}-${"y".repeat(19_000)}`,
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
||||
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
||||
// Title is indexed first so it always lands in the bounded window.
|
||||
assert.equal(fields[0], "big session");
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
|
||||
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
|
||||
// allocated newest-first so the most recent message remains searchable.
|
||||
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
|
||||
role: "user" as const,
|
||||
content: `${"x".repeat(19_900)} marker-${i}`,
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.equal(fields[0], "big session");
|
||||
// Oldest messages are dropped, the newest is retained.
|
||||
assert.ok(!fields.some((field) => field.includes("marker-0")));
|
||||
assert.ok(fields.some((field) => field.includes("marker-3")));
|
||||
// Output order stays chronological (marker-1 before marker-2).
|
||||
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
|
||||
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
|
||||
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
|
||||
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
|
||||
// collected (oldest) message must be truncated to exactly the remaining
|
||||
// budget instead of being discarded wholesale.
|
||||
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
|
||||
role: "user" as const,
|
||||
content: "a".repeat(19_000),
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
||||
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
||||
assert.equal(total, 64_000);
|
||||
// The oldest message fills the leftover budget with its freshest-available
|
||||
// head instead of being dropped entirely.
|
||||
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
|
||||
assert.ok(fields.some((field) => field.startsWith("aaa")));
|
||||
});
|
||||
|
||||
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
|
||||
const blob = "x".repeat(5_000_000);
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
|
||||
// content); serialization must stop at the field cap instead of
|
||||
// stringifying the whole argument object on every keystroke.
|
||||
name: "sftp_write_file",
|
||||
id: "t1",
|
||||
arguments: { path: "/tmp/report.md", content: blob },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
|
||||
assert.ok(serialized);
|
||||
// The serialized field (plus the tool name) must land under the total
|
||||
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
|
||||
assert.ok(serialized!.length <= 2_000);
|
||||
assert.ok(serialized!.includes("/tmp/report.md"));
|
||||
|
||||
// Short payloads still serialize fully and stay searchable.
|
||||
const searchable = createSession("b", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t2",
|
||||
arguments: { command: "systemctl restart nginx" },
|
||||
}],
|
||||
},
|
||||
]);
|
||||
assert.ok(
|
||||
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
|
||||
);
|
||||
});
|
||||
|
||||
test("wide tool call argument objects and long property names stay within the cap", () => {
|
||||
// A wide object: thousands of own properties must be iterated lazily
|
||||
// instead of materializing the full entries array on every keystroke.
|
||||
const wide: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
|
||||
// An unusually large property name must be capped before it is escaped.
|
||||
const longKey = "k".repeat(50_000);
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t1",
|
||||
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
|
||||
assert.ok(serialized);
|
||||
assert.ok(serialized!.length <= 2_000);
|
||||
// The oversized key (and the wide object past the cap) never materialize:
|
||||
// the long key is truncated before escaping.
|
||||
assert.ok(!fields.some((field) => field.length > 2_000));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted status text", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{ role: "user", content: "ship it" },
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
|
||||
});
|
||||
|
||||
// Regression: bounds enforcement must run once per scan, not on every cache
|
||||
// miss. Evicting per miss makes each rebuild drop the index of a later
|
||||
// session in the same scan, so any history longer than the entry cap
|
||||
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
|
||||
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
|
||||
const sessionCount = 80;
|
||||
const sessions = Array.from({ length: sessionCount }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
// Observe corpus (re)builds: a cache hit serves the prepared haystack
|
||||
// without touching `messages`, while a rebuild collects the fields again.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
readSessions.clear();
|
||||
const second = filterSessionHistory(sessions, "deploy");
|
||||
|
||||
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
|
||||
// every session retained by the last prune are cache hits. Per-miss
|
||||
// eviction would have rebuilt all 80 sessions here.
|
||||
const rebuilt = readSessions.size;
|
||||
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
|
||||
assert.deepEqual(second, sessions);
|
||||
// Eviction must retain the FRONT of the ranked list (the sessions the next
|
||||
// scan visits first) and rebuild only the oldest tail above the entry cap —
|
||||
// evicting the Map's first key would drop the head and thrash it instead.
|
||||
const rebuiltIds = [...readSessions].sort();
|
||||
assert.deepEqual(
|
||||
rebuiltIds,
|
||||
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
|
||||
// live sessions froze the cache's membership. New sessions are prepended to
|
||||
// the ranked list, so every subsequently created conversation (and its
|
||||
// immutable streaming updates) would rebuild its index on every keystroke
|
||||
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
|
||||
// could not correct it because the cache was already within both bounds.
|
||||
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
|
||||
const initial = Array.from({ length: 64 }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
filterSessionHistory(initial, "deploy");
|
||||
|
||||
// A newly created session is prepended: it ranks above every cached entry.
|
||||
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the thrash regression test above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// First scan over the new list: "fresh" misses (its index was never cached)
|
||||
// and must be admitted; the prune evicts the lowest-ranked tail instead.
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
readSessions.clear();
|
||||
const third = filterSessionHistory(sessions, "deploy");
|
||||
|
||||
assert.deepEqual(third, sessions);
|
||||
// The fresh, highest-ranked session stays cached; only the evicted tail
|
||||
// above the entry cap is rebuilt.
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s63"]);
|
||||
});
|
||||
|
||||
// Regression: the entry cap must be enforced at cache-admission time, not only
|
||||
// by the end-of-scan prune. When misses are small (empty/short searchable
|
||||
// content), the char budget consumes almost no headroom per insert, so
|
||||
// overshooting the entry cap mid-scan and letting the prune trim it made a
|
||||
// single keystroke over a very long history build and transiently retain one
|
||||
// index + Map entry per live session. Admission must instead evict the stale
|
||||
// lowest-ranked untouched tail to make room (the same entry the prune would
|
||||
// otherwise drop moments later), keeping the cache within the entry cap
|
||||
// throughout the scan.
|
||||
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
|
||||
const initial = Array.from({ length: 64 }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
filterSessionHistory(initial, "deploy");
|
||||
|
||||
// A newly created session is prepended: it ranks above every cached entry.
|
||||
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the regression tests above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The single scan over the prepended list must admit "fresh" by evicting
|
||||
// the stalest cached tail entry ("s63") during the scan: under the old
|
||||
// overshoot-then-prune behavior "s63" would stay cached until the prune,
|
||||
// so it would not be rebuilt in this same scan at all. Rebuilding it here
|
||||
// is the (bounded, one-entry) cost of never exceeding the entry cap
|
||||
// mid-scan.
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
|
||||
|
||||
// Recovery is unchanged: the fresh session stays cached, and only the
|
||||
// tail displaced below the entry cap rebuilds on the next keystroke.
|
||||
readSessions.clear();
|
||||
const next = filterSessionHistory(sessions, "deploy");
|
||||
assert.deepEqual(next, sessions);
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s63"]);
|
||||
});
|
||||
|
||||
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
|
||||
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
|
||||
// 64,000-character corpus cap) already consuming nearly the 4M-character
|
||||
// cache budget, a newly prepended high-ranked session was rejected without
|
||||
// evicting anything, and the end-of-scan prune — seeing a cache within both
|
||||
// bounds — kept the stale lower-ranked entries: the new large conversation
|
||||
// then rebuilt its index on every search keystroke.
|
||||
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
|
||||
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
|
||||
// index retaining ~192K chars (normalized fields + haystack + compact
|
||||
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
|
||||
const createLargeSession = (id: string) =>
|
||||
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
|
||||
|
||||
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
|
||||
filterSessionHistory(initial, "x");
|
||||
|
||||
// A newly created large session is prepended: it ranks above every cached
|
||||
// entry, and its index can only fit if the stale tail is evicted.
|
||||
const created = createLargeSession("fresh");
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the regression tests above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// First scan over the new list: "fresh" misses (its index was never cached)
|
||||
// and must be admitted by evicting the stalest cached tail entry.
|
||||
filterSessionHistory(sessions, "x");
|
||||
readSessions.clear();
|
||||
const second = filterSessionHistory(sessions, "x");
|
||||
|
||||
assert.deepEqual(second, sessions);
|
||||
// The fresh, highest-ranked session stays cached; only the evicted stalest
|
||||
// tail session (which no longer fits the char budget) rebuilds.
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s19"]);
|
||||
// And the steady state holds on subsequent scans too.
|
||||
readSessions.clear();
|
||||
filterSessionHistory(sessions, "x");
|
||||
assert.deepEqual([...readSessions], ["s19"]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
|
||||
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
|
||||
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
|
||||
|
||||
filterSessionHistory([kept, removed], "nginx");
|
||||
// Deleting a session then clearing the query goes through the blank path,
|
||||
// which must prune the cache against the remaining list.
|
||||
filterSessionHistory([kept], "");
|
||||
// Subsequent searches are unaffected: the surviving session stays cached
|
||||
// (its cached index is still there) and matching still works.
|
||||
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
|
||||
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
|
||||
// The removed session's index is gone entirely (no match via a stale cache).
|
||||
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
|
||||
});
|
||||
|
||||
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
|
||||
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
||||
|
||||
filterSessionHistory([session], "nginx");
|
||||
clearSessionHistorySearchCache();
|
||||
// After the clear, the next search rebuilds from scratch and still matches.
|
||||
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
|
||||
assert.deepEqual(filterSessionHistory([session], ""), [session]);
|
||||
});
|
||||
|
||||
test("independent search instances own isolated caches", () => {
|
||||
const first = createSessionHistorySearch();
|
||||
const second = createSessionHistorySearch();
|
||||
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
|
||||
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
// Clearing one instance must not disturb the other's state: both stay fully
|
||||
// functional afterwards (each store is private to its instance).
|
||||
second.clearSessionHistorySearchCache();
|
||||
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
|
||||
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
||||
|
||||
// A pasted multi-megabyte query must not be re-normalized per session: it is
|
||||
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
|
||||
// input and normalized once per scan, so matching falls back to the capped
|
||||
// prefix (which still spans tokens here).
|
||||
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
|
||||
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
|
||||
// The uncapped-looking but normal query still matches exactly.
|
||||
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
|
||||
});
|
||||
|
||||
// Keep the author's long-title rescan protection local to history. Shared
|
||||
// search must still match complete fields, including later joined fields.
|
||||
test("history pinyin cache preserves long titles across same-order rescans", () => {
|
||||
resetPinyinCacheForTests();
|
||||
const sessions = Array.from({ length: 17 }, (_, index) =>
|
||||
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
|
||||
}
|
||||
const stats = getPinyinCacheStatsForTests();
|
||||
assert.equal(stats.size, sessions.length);
|
||||
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
|
||||
const sessions = [
|
||||
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
|
||||
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
|
||||
];
|
||||
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
getSessionSearchTitle,
|
||||
matchesSessionSearchIndex,
|
||||
prepareSessionSearchIndex,
|
||||
type SessionHistorySearchTarget,
|
||||
type SessionSearchIndex,
|
||||
} from '../../domain/sessionHistorySearch';
|
||||
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
|
||||
|
||||
/**
|
||||
* Memory bounds for the per-session search index cache. `filterSessionHistory`
|
||||
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
|
||||
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
|
||||
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
|
||||
* haystack per keystroke scans tens of megabytes and freezes the renderer.
|
||||
*
|
||||
* The raw collected fields are NOT retained (only the prepared search fields),
|
||||
* and
|
||||
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
|
||||
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
|
||||
* unbounded cache would let one entry per live session (several normalized
|
||||
* copies of each session's searchable corpus) permanently pin hundreds of
|
||||
* megabytes in a long-running process. Both bounds hold after every scan's
|
||||
* prune and are enforced eagerly on every insert (see
|
||||
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
|
||||
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
|
||||
* in-flight scan has not yet visited — they rank below every visited session,
|
||||
* so this can never cycle-evict an entry the same scan still needs); if no
|
||||
* such entry remains, the insert is skipped — the miss is then rebuilt on the
|
||||
* next search, exactly what the end-of-scan prune would have done to it.
|
||||
* Skipping is only reachable for misses ranked below every cached entry, so
|
||||
* cache membership never freezes for freshly prepended high-ranked sessions:
|
||||
* at their (earliest) visit nothing is touched yet and the stale tail yields.
|
||||
*/
|
||||
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
|
||||
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
|
||||
|
||||
/**
|
||||
* Mutable cache state owned by exactly one search client (one mounted history
|
||||
* drawer). Keeping this out of module scope means several concurrently mounted
|
||||
* drawers (one per terminal tab) never share a cache: each drawer's prune only
|
||||
* drops entries no longer in that drawer's scoped session list, and one
|
||||
* drawer's unmount releases only its own store (previously a shared
|
||||
* module-level cache was discarded for every drawer on any unmount and pruned
|
||||
* against whichever drawer scanned last).
|
||||
*/
|
||||
export type SessionHistorySearchStore = {
|
||||
/**
|
||||
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
|
||||
* object identity is a reliable cache key: each session's normalized/compact
|
||||
* haystacks are built once and reused until that session's object changes.
|
||||
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
|
||||
*/
|
||||
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
|
||||
/**
|
||||
* Session keys the in-flight `filterSessionHistory` scan has already visited
|
||||
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
|
||||
* never evicts a touched key: it either ranks above the current insert (it was
|
||||
* visited earlier in this newest-first scan) or it is the current session
|
||||
* itself (whose stale entry a rebuild is about to replace), and evicting either
|
||||
* would cycle-evict entries the same scan still needs.
|
||||
*/
|
||||
scanTouched: Set<SessionHistorySearchTarget>;
|
||||
/**
|
||||
* Running total of the cache's retained characters. Maintained at every
|
||||
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
|
||||
* O(1) per miss instead of re-summing the whole cache on every keystroke
|
||||
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
|
||||
* across a scan).
|
||||
*/
|
||||
cacheChars: number;
|
||||
};
|
||||
|
||||
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
|
||||
function indexRetainedChars(index: SessionSearchIndex): number {
|
||||
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
|
||||
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
|
||||
// from `haystack`), so their combined length approximates the cache's
|
||||
// per-entry memory cost. Counting only the joined haystacks would
|
||||
// undercount by roughly a full copy of the corpus and let the char budget
|
||||
// retain ~50% more than advertised.
|
||||
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
|
||||
for (const field of index.prepared.normalizedFields) total += field.length;
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a freshly built index may be inserted into the cache without
|
||||
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
|
||||
* that re-enforces them runs once per scan, so admitting every prepared index
|
||||
* would let a single search over hundreds/thousands of uncached sessions
|
||||
* transiently pin the whole corpus (several normalized haystacks per session)
|
||||
* or hold one index and Map entry per live session — for empty/short sessions
|
||||
* the char budget consumes little room, so the entry cap would be enforced
|
||||
* only after the complete scan — before pruning, briefly allocating memory
|
||||
* for the whole scanned list.
|
||||
*
|
||||
* Rather than rejecting inserts outright (which froze cache membership the
|
||||
* same way a hard entry-count gate would), lack of headroom in either bound
|
||||
* first evicts the cache's stale tail — the lowest-ranked entries this scan
|
||||
* has not yet visited. Because the scan visits the ranked list newest-first,
|
||||
* every untouched entry ranks below the current insert, so evicting any of
|
||||
* them never drops an index a later visit of the same scan still needs (no
|
||||
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
|
||||
* if no untouched entry remains does the insert get skipped — that is
|
||||
* reachable only for misses ranked below every cached entry, which the
|
||||
* end-of-scan prune would have evicted from the Map's back anyway, so the
|
||||
* skip changes no steady-state membership: the index is simply rebuilt on
|
||||
* the next search.
|
||||
*
|
||||
* The bounds check is applied to NEW entries only: cache hits merely
|
||||
* re-append an existing entry (no growth), and a rebuild replaces the same
|
||||
* session's entry (no entry-count growth; the char delta against the
|
||||
* replaced index is checked).
|
||||
*/
|
||||
function canCacheSessionSearchIndex(
|
||||
store: SessionHistorySearchStore,
|
||||
previous: SessionSearchIndex | undefined,
|
||||
index: SessionSearchIndex,
|
||||
): boolean {
|
||||
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
|
||||
// A rebuild replaces its own entry (no entry-count growth); a new entry
|
||||
// grows the Map by one.
|
||||
const growsEntries = !previous;
|
||||
if (
|
||||
deltaChars <= 0
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
|
||||
// the Map's order is old-insertion-order front to back with this scan's
|
||||
// touched keys re-appended at the end, so scanning the keys back-to-front
|
||||
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
|
||||
// entries first.
|
||||
let entriesToFree = growsEntries
|
||||
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|
||||
: 0;
|
||||
const keysInInsertionOrder = [...store.cache.keys()];
|
||||
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
|
||||
if (entriesToFree <= 0
|
||||
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
|
||||
break;
|
||||
}
|
||||
const key = keysInInsertionOrder[i];
|
||||
if (store.scanTouched.has(key)) continue;
|
||||
const evicted = store.cache.get(key);
|
||||
if (!evicted) continue;
|
||||
store.cacheChars -= indexRetainedChars(evicted);
|
||||
store.cache.delete(key);
|
||||
if (growsEntries) entriesToFree--;
|
||||
}
|
||||
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
|
||||
}
|
||||
|
||||
function getSessionSearchIndex(
|
||||
store: SessionHistorySearchStore,
|
||||
session: SessionHistorySearchTarget,
|
||||
untitledLabel: string,
|
||||
): SessionSearchIndex {
|
||||
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
|
||||
const cached = store.cache.get(session);
|
||||
if (cached && cached.displayTitle === indexedTitle) {
|
||||
// Re-append on every visit so the cache's insertion order mirrors the
|
||||
// latest scan's visitation order (the ranked, newest-first session list);
|
||||
// `pruneSessionSearchIndexCache` relies on that order when evicting.
|
||||
store.cache.delete(session);
|
||||
store.cache.set(session, cached);
|
||||
store.scanTouched.add(session);
|
||||
return cached;
|
||||
}
|
||||
const index = prepareSessionSearchIndex(session, untitledLabel);
|
||||
// Mark the session touched before the gate so the gate's admission eviction
|
||||
// can never pick this session's own (stale) entry — the rebuild below is
|
||||
// about to replace it, and evicting it would corrupt the running char total.
|
||||
store.scanTouched.add(session);
|
||||
if (canCacheSessionSearchIndex(store, cached, index)) {
|
||||
store.cache.set(session, index);
|
||||
// Keep the eager gate's running total in sync (also covers the
|
||||
// display-title-change rebuild, which replaces an existing entry).
|
||||
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
|
||||
* on every cache miss: evicting during the scan would make each miss drop the
|
||||
* index of a later session in the same scan, so any history longer than the
|
||||
* entry cap would cycle-evict the whole cache and rebuild every session's
|
||||
* haystack on every keystroke. Deferring eviction keeps every retained entry
|
||||
* usable within a scan while still bounding the cache — first by dropping
|
||||
* indexes for sessions no longer in the scanned list, then by evicting the
|
||||
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
|
||||
* until both bounds hold, preserving the front of the ranked list.
|
||||
*
|
||||
* In practice the eviction loop is cheap: both bounds are enforced eagerly
|
||||
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
|
||||
* overshoots the entry cap or char budget mid-scan — the prune only drops
|
||||
* indexes for sessions that left the scanned list and re-syncs the running
|
||||
* char total.
|
||||
*/
|
||||
function pruneSessionSearchIndexCache(
|
||||
store: SessionHistorySearchStore,
|
||||
sessions: readonly SessionHistorySearchTarget[],
|
||||
): void {
|
||||
const live = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const key of [...store.cache.keys()]) {
|
||||
if (!live.has(key)) store.cache.delete(key);
|
||||
}
|
||||
let retained = 0;
|
||||
for (const cachedIndex of store.cache.values()) {
|
||||
retained += indexRetainedChars(cachedIndex);
|
||||
}
|
||||
// Re-sync the eager gate's running total: the sum above is authoritative
|
||||
// here (once per scan), so any drift accumulated mid-scan is corrected.
|
||||
store.cacheChars = retained;
|
||||
// Evict from the END of the Map until both bounds hold. Every scan visits
|
||||
// the full ranked session list newest-first and `getSessionSearchIndex`
|
||||
// re-appends each visited entry, so after a scan the Map's insertion order
|
||||
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
|
||||
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
|
||||
// Map order) would therefore drop the newest, highest-ranked sessions and
|
||||
// retain the stalest tail — every subsequent keystroke would rebuild every
|
||||
// session above the cache cap. Evicting the back instead preserves the
|
||||
// front of the ranked list. The single remaining entry is always kept, even
|
||||
// if it alone exceeds the char budget (a single entry is bounded by ~192K
|
||||
// chars by the collector caps).
|
||||
// Snapshot the keys once: prune only deletes (never inserts), so popping
|
||||
// from this array visits keys exactly in Map order, back to front.
|
||||
const keysInInsertionOrder = [...store.cache.keys()];
|
||||
while (
|
||||
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|
||||
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
) {
|
||||
// A single entry can never push the cache past the char budget (it is
|
||||
// bounded by ~192K chars by the collector caps), so keep the last entry.
|
||||
if (store.cache.size <= 1) break;
|
||||
const evictKey = keysInInsertionOrder.pop();
|
||||
if (evictKey === undefined) break;
|
||||
const evicted = store.cache.get(evictKey);
|
||||
if (evicted) retained -= indexRetainedChars(evicted);
|
||||
store.cache.delete(evictKey);
|
||||
}
|
||||
store.cacheChars = retained;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every cached index and touched-session reference for one store
|
||||
* (invoked for the drawer's unmount lifecycle via the per-drawer search
|
||||
* instance). Cache keys are the session objects themselves, so clearing fully
|
||||
* drops all strong session references — the retained haystack characters
|
||||
* become garbage together with their sessions.
|
||||
*/
|
||||
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
|
||||
store.cache.clear();
|
||||
store.scanTouched.clear();
|
||||
store.cacheChars = 0;
|
||||
}
|
||||
|
||||
export type SessionHistorySearchOptions = {
|
||||
/**
|
||||
* Localized fallback label rendered by the drawer for sessions with an
|
||||
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
|
||||
* displayed title so searching for the visible label finds the session.
|
||||
*/
|
||||
untitledLabel?: string;
|
||||
};
|
||||
|
||||
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
|
||||
store: SessionHistorySearchStore,
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
options: SessionHistorySearchOptions = {},
|
||||
): T[] {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
// The blank-query path skips the scan entirely; release any stale
|
||||
// scan-touched state so it cannot pin visited sessions (and their
|
||||
// message/attachment memory) while the drawer is open without a query.
|
||||
// Normally this set is already empty — the nonblank scan clears it in its
|
||||
// `finally` — but clearing here too keeps every entry path leak-free.
|
||||
store.scanTouched.clear();
|
||||
// Cache keys are the session objects themselves, so an entry for a
|
||||
// deleted/replaced session strongly retains that session (and its full
|
||||
// message/attachment payloads). Prune against the current list here too:
|
||||
// the blank-query path may run while the drawer stays open after
|
||||
// deletions, and a later nonblank search that would prune otherwise may
|
||||
// never happen (e.g. the user just closes the drawer).
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
return [...sessions];
|
||||
}
|
||||
const untitledLabel = options.untitledLabel ?? '';
|
||||
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
|
||||
// query would otherwise be re-normalized (NFKC), tokenized and compacted
|
||||
// inside `matchesSessionSearchIndex` for every session, freezing the
|
||||
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
|
||||
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
|
||||
// — so the meaning of over-limit queries stays local to session history and
|
||||
// the normalized forms are computed a single time, letting the per-session
|
||||
// work below only do `includes` scans over the cached haystacks.
|
||||
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
|
||||
// A new scan starts with no visited sessions: the touched set records only
|
||||
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
|
||||
// previous scan's keys for keys the current scan still needs.
|
||||
store.scanTouched.clear();
|
||||
try {
|
||||
// Drop entries still held by sessions that left the scan list (e.g. the
|
||||
// history shrank or session objects changed since the previous scan). Those
|
||||
// entries are never visited by this scan — the end-of-scan prune would
|
||||
// remove them too — but dropping them up front keeps their characters
|
||||
// available to this scan's inserts (via the eager char gate). Cheap: one
|
||||
// Set plus one pass over the cached keys.
|
||||
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const key of [...store.cache.keys()]) {
|
||||
if (!currentLive.has(key)) {
|
||||
const removed = store.cache.get(key);
|
||||
if (removed) store.cacheChars -= indexRetainedChars(removed);
|
||||
store.cache.delete(key);
|
||||
}
|
||||
}
|
||||
// Scan all sessions first (a miss admits its index into the cache —
|
||||
// evicting this scan's untouched stale tail whenever either bound lacks
|
||||
// headroom, and skipping the insert only when no untouched entry remains),
|
||||
// then prune entries that fell out of the scan list at the end of the
|
||||
// scan.
|
||||
const result = sessions.filter((session) => {
|
||||
const index = getSessionSearchIndex(store, session, untitledLabel);
|
||||
return matchesSessionSearchIndex(preparedQuery, index);
|
||||
});
|
||||
// Prune against the full scanned list (not the filtered result): sessions
|
||||
// that merely did not match this query must keep their cached indexes.
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
return result;
|
||||
} finally {
|
||||
// The touched set only guards against cycle-eviction within the current
|
||||
// scan; once the scan ends (even if it throws), release its strong
|
||||
// references to session objects so deleted/replaced sessions and their
|
||||
// large message/attachment payloads aren't retained until the next
|
||||
// nonblank search.
|
||||
store.scanTouched.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One stateful search instance per consumer (one mounted session history
|
||||
* drawer). The cache lives in the returned instance's store instead of module
|
||||
* scope, so concurrently mounted drawers (one per terminal tab) never share
|
||||
* state: an unmount of one drawer clears only that drawer's indexes, and each
|
||||
* drawer's prune is scoped to its own session list.
|
||||
*/
|
||||
export type SessionHistorySearch = {
|
||||
filterSessionHistory: <T extends SessionHistorySearchTarget>(
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => T[];
|
||||
clearSessionHistorySearchCache: () => void;
|
||||
};
|
||||
|
||||
export function createSessionHistorySearch(): SessionHistorySearch {
|
||||
const store: SessionHistorySearchStore = {
|
||||
cache: new Map(),
|
||||
scanTouched: new Set(),
|
||||
cacheChars: 0,
|
||||
};
|
||||
return {
|
||||
filterSessionHistory(sessions, query, options = {}) {
|
||||
return filterSessionHistoryStore(store, sessions, query, options);
|
||||
},
|
||||
clearSessionHistorySearchCache() {
|
||||
clearSessionHistorySearchStore(store);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,7 +148,8 @@ test("session peer windows do not run main-window startup effects", () => {
|
||||
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
|
||||
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
// Detached windows share opacity, while general settings broadcasts stay off.
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
|
||||
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
|
||||
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
|
||||
@@ -209,3 +210,13 @@ test("restore terminal cwd setting participates in cross-window settings sync",
|
||||
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
});
|
||||
|
||||
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
|
||||
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
|
||||
assert.ok(expression, "renderer settings options must be explicit");
|
||||
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
|
||||
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
|
||||
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
|
||||
assert.equal(resolveOptions(false, false), undefined);
|
||||
});
|
||||
@@ -36,6 +36,8 @@ export type SettingsChromeSnapshot = {
|
||||
restoreTerminalCwd: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen: boolean;
|
||||
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,6 +70,8 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
restoreTerminalCwd: true,
|
||||
terminalSidePanelAutoOpen: false,
|
||||
terminalSidePanelAutoOpenTab: 'ai',
|
||||
localShellSidePanelAutoOpen: false,
|
||||
localShellSidePanelAutoOpenTab: 'scripts',
|
||||
} satisfies SettingsChromeSnapshot);
|
||||
|
||||
export function settingsChromeSnapshotsEqual(
|
||||
@@ -92,7 +96,9 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.showTabNumberBadges === b.showTabNumberBadges
|
||||
&& a.restoreTerminalCwd === b.restoreTerminalCwd
|
||||
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
|
||||
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
|
||||
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab
|
||||
&& a.localShellSidePanelAutoOpen === b.localShellSidePanelAutoOpen
|
||||
&& a.localShellSidePanelAutoOpenTab === b.localShellSidePanelAutoOpenTab;
|
||||
}
|
||||
|
||||
class SettingsChromeStore {
|
||||
|
||||
Loaded 100 of 1008 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user