Compare commits

...
Author SHA1 Message Date
bincxz 97d167eef7 fix(terminal): keep prompt security renderer-compatible 2026-08-24 16:07:09 +08:00
陈大猫 b852f267f8 Merge pull request #3127 from binaricat/codex/fix-3123-ai-new-chat
fix(ai): restore New Chat in persisted workspaces
2026-08-24 15:33:22 +08:00
bincxz af1b624747 fix(ai): restore New Chat in persisted workspaces 2026-08-24 15:22:57 +08:00
陈大猫 ff5c062bf1 Merge pull request #3125 from binaricat/codex/fix-3119-mosh-highlighting
fix(terminal): preserve highlights across Mosh repaints
2026-08-24 13:31:09 +08:00
陈大猫 539594845f Merge pull request #3124 from binaricat/codex/fix-3117-mosh-progress
fix(terminal): keep Mosh progress until ready
2026-08-24 13:30:58 +08:00
bincxz 3e2fa04b08 fix(terminal): clamp Mosh repaint rows 2026-08-24 12:14:43 +08:00
bincxz 5718566ddb fix(terminal): track C1 Mosh row moves 2026-08-24 12:08:14 +08:00
bincxz e345e59219 fix(terminal): support C1 Mosh repaint controls 2026-08-24 12:01:18 +08:00
bincxz 88b0a908a9 fix(terminal): keep sparse Mosh repaint rows 2026-08-24 11:53:30 +08:00
bincxz 06e8be3df9 fix(terminal): track saved cursor origin mode 2026-08-24 11:49:04 +08:00
bincxz e8f51ea63e fix(terminal): handle grouped cursor origin modes 2026-08-24 11:43:34 +08:00
bincxz 15c8b44818 fix(terminal): track cursor origin mode 2026-08-24 11:39:43 +08:00
bincxz 8df32d5663 fix(terminal): bound fragmented Mosh repaint work 2026-08-24 11:33:58 +08:00
bincxz 00ad6198b8 fix(terminal): catch up pressured Mosh repaints 2026-08-24 11:17:42 +08:00
bincxz 0ac9ca4245 fix(terminal): cover scrolling Mosh repaints 2026-08-24 11:14:54 +08:00
bincxz fff99e46f8 fix(build): package Mosh prompt classifier 2026-08-24 11:05:54 +08:00
bincxz 73936c7792 fix(terminal): cover split Mosh repaint rows 2026-08-24 11:01:42 +08:00
bincxz 8be8411a1f fix(terminal): share interactive prompt detection 2026-08-24 11:01:42 +08:00
bincxz 1d633431c1 fix(terminal): distinguish interactive Mosh prompts 2026-08-24 10:56:24 +08:00
bincxz 21a0903b32 fix(terminal): cover full Mosh repaint range 2026-08-24 10:51:29 +08:00
bincxz 918d0d81f0 fix(terminal): keep Mosh progress until ready 2026-08-24 10:44:49 +08:00
bincxz dd45af8fe3 fix(terminal): preserve highlights across Mosh repaints 2026-08-24 10:44:49 +08:00
github-actions[bot]andgithub-actions[bot] e171ee1fe1 chore: update star history charts [skip ci] (#3121)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 00:06:00 +00:00
陈大猫 6c5251c936 Merge pull request #3118 from binaricat/codex/fix-3113-disabled-close-tab-shortcut
fix: honor disabled close-tab shortcut on macOS
2026-08-23 23:49:49 +08:00
陈大猫 eee2191c8a Merge pull request #3115 from binaricat/codex/fix-3114-option-block-selection
fix(terminal): restore macOS Option block selection
2026-08-23 23:46:56 +08:00
bincxz 2e51ff8b54 fix: route reassigned shortcut from editor 2026-08-23 23:11:59 +08:00
bincxz 4525ea1c03 fix: use platform modifier for editor close 2026-08-23 23:06:56 +08:00
bincxz 066e658afd fix: forward freed shortcut in settings 2026-08-23 22:58:21 +08:00
bincxz 88387b9c2b fix: respect close-tab binding in editor 2026-08-23 22:56:36 +08:00
bincxz 95ee17f3cc fix(terminal): preserve xterm source map columns 2026-08-23 22:53:20 +08:00
bincxz 4ff20453f2 fix: preserve Cmd+W focus and settings behavior 2026-08-23 22:51:42 +08:00
bincxz 155401943b test(terminal): require remote drag motion reports 2026-08-23 22:47:30 +08:00
bincxz e543266b02 ci: deduplicate macOS selection trigger 2026-08-23 22:42:34 +08:00
bincxz 13b1bf506e fix: honor disabled close-tab shortcut on macOS 2026-08-23 22:42:29 +08:00
bincxz f6050e1e44 Merge remote-tracking branch 'origin/codex/fix-3114-option-block-selection' into codex/fix-3114-option-block-selection 2026-08-23 22:41:40 +08:00
bincxz e7b58a8831 merge: preserve Option-as-Meta selection behavior 2026-08-23 22:41:13 +08:00
bincxz 992a303818 fix(terminal): validate patched xterm across dev and CI 2026-08-23 22:41:08 +08:00
bincxz a3ab956999 test(terminal): enforce macOS selection regression 2026-08-23 22:38:12 +08:00
bincxz 21880ef1ea fix(terminal): respect Option-as-Meta during block selection 2026-08-23 22:32:05 +08:00
bincxz 308df815d8 fix(terminal): restore macOS Option block selection 2026-08-23 22:20:06 +08:00
陈大猫 b7de0bb441 Merge pull request #3095 from binaricat/codex/fix-3087-disconnect-notice
[Bug] Keep terminal history visible after disconnect
2026-08-23 01:18:28 +08:00
bincxz b11eaa172f fix: disable reconnect menus during preparation 2026-08-23 00:52:39 +08:00
bincxz 809cbfdf79 fix: publish reconnect state immediately 2026-08-23 00:44:05 +08:00
bincxz d5198f45ca fix: stabilize manual reconnect interactions 2026-08-23 00:35:38 +08:00
bincxz a233d3fb91 fix: keep disconnect notice from stealing focus 2026-08-23 00:13:11 +08:00
bincxz 401d9d7a91 test: accept reconnect cleanup guard 2026-08-23 00:03:34 +08:00
bincxz 82e2f7287d fix: keep reconnect notice stable 2026-08-22 23:50:51 +08:00
bincxz dd30ff0e20 merge: sync main and keep manual reconnect non-blocking 2026-08-22 23:40:05 +08:00
陈大猫 bb655caaa1 Merge pull request #3112 from LaiYueJi/docs/readme-zh-tw
docs: add Traditional Chinese README and fix localized download badges
2026-08-22 23:21:04 +08:00
LaiYueJi 3000837f4e docs: link AGENTS.md with the correct capitalization
The repository ships AGENTS.md, but every README linked to lowercase
agents.md. GitHub serves blob paths case-sensitively, so the architecture
guide link 404s on all four pages.

Fixed in README.md, README.zh-CN.md and README.ja-JP.md as well, since the
same broken link exists there.
2026-08-22 19:58:36 +08:00
LaiYueJi 6583f422cd docs: add Traditional Chinese README and fix localized download badges
Add README.zh-TW.md for Traditional Chinese (Taiwan) readers and link it
from the language switcher in every README.

The localized download badges were also broken. README.zh-CN.md and
README.ja-JP.md put raw non-ASCII text straight into the shields.io query
string (label=下载最新版, label=最新版をダウンロード). shields.io rejects
those with HTTP 400, so both rendered as a broken image. Percent-encoding
the label fixes it; all four badges now return HTTP 200.

- add README.zh-TW.md, written for Taiwan usage rather than converted from
  Simplified Chinese (伺服器, 用戶端, 分頁, 工作階段, 檔案總管, 程式碼簽章,
  相依套件, 叢集, 權杖, 序列埠)
- percent-encode the badge label in README.zh-CN.md and README.ja-JP.md
- add 繁體中文 to the language switcher in all four READMEs
- allowlist README.zh-TW.md in .gitignore, which ignores root files by
  default
2026-08-22 19:37:16 +08:00
陈大猫 e4c1ab2a8f Merge pull request #3094 from binaricat/codex/issue-3092-sftp-folder-conflict
fix(sftp): make folder conflict merge the safe default
2026-08-22 15:45:15 +08:00
陈大猫 a9e27f9688 Merge pull request #3098 from binaricat/codex/issue-3081-focus-side-panel
[Feature] Magnify and restore the active split pane
2026-08-22 15:39:11 +08:00
bincxz c4354d9451 fix: restore magnified pane from close control 2026-08-22 15:28:30 +08:00
bincxz 08b9027ef1 fix: separate focus and pane magnification actions 2026-08-22 14:49:29 +08:00
bincxz 486a8d20e1 Merge remote-tracking branch 'origin/main' into codex/issue-3081-focus-side-panel
# Conflicts:
#	application/i18n/locales/zh-TW/vault.ts
2026-08-22 11:51:53 +08:00
陈大猫 7a268b7726 Merge pull request #3110 from 0verme/fix/3109-private-key-validation
fix(ssh): reject identity files without private key material
2026-08-22 11:14:02 +08:00
何嘉佳 54fbcc5531 🐛 fix(ssh): reject identity files without private key material
Validate ssh2 parser results for actual private material and stop raw key fallback after resolution failures. Add generated-key regression coverage for public-only and malformed identity inputs while preserving encrypted-key handling.
2026-08-22 10:33:49 +08:00
陈大猫 6bc131cc69 Merge pull request #3108 from LaiYueJi/fix/vault-drag-stuck-dim
fix(vault): clear host drag state when the source node is unmounted mid-drag
2026-08-22 10:03:28 +08:00
陈大猫 913789992d Merge pull request #3107 from LaiYueJi/fix/zh-tw-locale-parity
fix(i18n): restore zh-TW key parity and use Taiwan terminology
2026-08-22 10:03:05 +08:00
LaiYueJi 3bf2242ac4 fix(vault): clear host drag state when the source node is unmounted
Dragging a host card in grid view left it stuck at opacity-45 until the
user started another drag on it.

The grid preview reorder runs from onDragOverCapture, so the dragged card
can move into a different virtual row while the drag is still active. In
VirtualizedHostCollection each row is a separate React parent, so crossing
a row boundary unmounts and remounts the card. The browser still dispatches
dragend at the original node, but a detached node no longer bubbles the
event up to the container's onDragEndCapture, so draggingHostId was never
cleared. A short drag that stayed inside one row kept the node mounted,
which is why dragging again appeared to fix it.

- bind a native dragend listener on the source node at dragstart; it is
  dispatched at that node and still fires after React unmounts it
- always reset drag state in onDropCapture: after a grid preview reorder
  the card under the cursor is the dragged card itself, so
  draggedHostId === targetHostId fell through every branch without
  resetting
- apply the same fix in useVaultItemReorder, which backs the keychain,
  port forwarding, proxy and known-hosts lists, and in SnippetsManager
2026-08-21 23:02:55 +08:00
LaiYueJi 8988ebe903 fix(i18n): restore zh-TW key parity and use Taiwan terminology
zh-TW was missing 78 keys. I18nProvider resolves a missing zh-TW key
through the base "zh" language, and that lookup matches zh-CN first in
MESSAGES_BY_LOCALE, so Traditional Chinese users saw Simplified Chinese
for the whole App Lock surface rather than an English fallback.

- add the 78 missing keys: appLock.*, settings.appLock.*, topTabs.lockApp,
  dialog.createWorkspace.title, field.selectHosts, placeholder.searchHosts
- fix Simplified leftovers: 云端 -> 雲端, 筆記本里 -> 筆記本裡, 賬密 -> 帳密
- switch mainland wording to Taiwan usage: 分組 -> 群組, 標籤頁 -> 分頁,
  身份 -> 身分, 新建 -> 新增, 地址 -> 位址, 重置 -> 重設, 快捷鍵 -> 快速鍵,
  生成 -> 產生, 審批/批准 -> 核准, 波特率 -> 鮑率, 停止位 -> 停止位元,
  拖拽 -> 拖曳, 響應 -> 回應, 字號 -> 字型大小, 重啟 -> 重新啟動

zh-TW now covers every key present in en and zh-CN.
2026-08-21 23:02:42 +08:00
bincxz 7c45ce8c19 test: cover magnified terminal interaction state 2026-08-21 17:39:08 +08:00
bincxz fd3b648a87 fix: clear stale pane magnification targets 2026-08-21 17:31:14 +08:00
bincxz 75e46b5584 fix: contain keyboard focus in pane overlays 2026-08-21 17:27:12 +08:00
bincxz 8191f0b67b fix: keep focus inside the magnified pane 2026-08-21 17:23:44 +08:00
bincxz 623aa97e72 test: keep SFTP magnification helper refresh-safe 2026-08-21 17:18:06 +08:00
bincxz 2897a18f1e feat: magnify the active split pane 2026-08-21 17:15:25 +08:00
bincxz cf01441dae Merge remote-tracking branch 'origin/main' into codex/issue-3081-focus-side-panel 2026-08-21 16:25:32 +08:00
bincxz 95dd8b62c4 revert: replace pane focus implementation 2026-08-21 16:25:24 +08:00
陈大猫 ab3eae409a Merge pull request #3105 from binaricat/codex/issue-3102-ai-response-timeout
feat(ai): make response wait configurable
2026-08-21 15:43:15 +08:00
bincxz 79aafcdb1a feat(ai): make response wait configurable 2026-08-21 15:39:01 +08:00
陈大猫 d2a80e04d4 Merge pull request #3104 from binaricat/codex/issue-1181-shared-transport-timeout
fix(ssh): preserve shared sessions after PID probe timeout
2026-08-21 15:34:32 +08:00
bincxz 02addeaea9 fix(ssh): preserve shared sessions after PID probe timeout 2026-08-21 15:16:29 +08:00
bincxz e1f915a767 fix: limit pane focus transitions 2026-08-20 17:57:50 +08:00
bincxz e70f60f270 fix: complete pane focus interactions 2026-08-20 17:44:17 +08:00
bincxz 5db74fbb2d feat: add focus mode for split panes 2026-08-20 17:11:56 +08:00
bincxz 29c9610315 fix(sftp): announce transfer center replace risk 2026-08-20 17:03:48 +08:00
陈大猫 afc02116a8 Merge pull request #3096 from binaricat/codex/fix-3090-sftp-search-shortcut
fix(sftp): open filter with search shortcut
2026-08-20 17:01:59 +08:00
陈大猫 66310a1172 Merge pull request #3097 from binaricat/codex/fix-3088-autocomplete-history-refresh
fix(terminal): refresh history suggestions as input changes
2026-08-20 16:55:45 +08:00
bincxz 399657247d fix(sftp): preserve terminal search focus 2026-08-20 16:44:07 +08:00
bincxz ad1c5c9a3a fix(sftp): fail closed on unknown folder targets 2026-08-20 16:41:50 +08:00
bincxz d5bd69ff90 test(terminal): cover shorter path history replacements 2026-08-20 16:39:32 +08:00
bincxz 60ea41523a fix(sftp): handle search from editable controls 2026-08-20 16:36:43 +08:00
bincxz 78d50b78da fix(terminal): preserve matching path history rows 2026-08-20 16:34:12 +08:00
bincxz 7a43157ae1 fix: index disconnect notice setting 2026-08-20 16:26:29 +08:00
陈大猫 31e75e114e Merge pull request #3093 from binaricat/codex/fix-3082-split-shortcuts
[Bug] Align horizontal and vertical split shortcuts
2026-08-20 16:24:32 +08:00
bincxz a6180c7db1 fix: sync disconnect notice preference 2026-08-20 16:21:40 +08:00
bincxz 78d6662ce8 fix: update reconnect notice before wake 2026-08-20 16:18:46 +08:00
bincxz 9f72eb6469 fix(sftp): keep folder conflicts merge-first 2026-08-20 16:17:44 +08:00
bincxz e882ec01f7 test(terminal): cover fuzzy history deletion 2026-08-20 16:15:58 +08:00
bincxz 91c611c9f3 fix: keep reconnect notice state current 2026-08-20 16:15:00 +08:00
bincxz 6f470b3142 test(sftp): verify search shortcut passthrough 2026-08-20 16:14:45 +08:00
bincxz d8d14253d6 fix(terminal): keep autocomplete history aligned with input 2026-08-20 16:12:01 +08:00
bincxz 14f4b31097 fix: keep auto reconnect non-blocking 2026-08-20 16:09:15 +08:00
bincxz 5245ebbaa0 fix(sftp): open filter with search shortcut 2026-08-20 16:08:41 +08:00
bincxz e9f2a7c883 fix(sftp): align folder conflict actions 2026-08-20 16:07:32 +08:00
bincxz d7d711e1e3 fix: add non-blocking disconnect notices 2026-08-20 16:00:59 +08:00
bincxz 9c63b40ddf test(terminal): stabilize split menu coverage 2026-08-20 15:52:52 +08:00
bincxz a8e5b4992c fix(sftp): make folder conflict merge the safe default 2026-08-20 15:50:16 +08:00
bincxz 490554d6a6 fix(terminal): align split menu icons 2026-08-20 15:49:18 +08:00
bincxz f7a8e1557b fix(terminal): align split menu shortcuts 2026-08-20 15:38:15 +08:00
陈大猫 bd8f7ee7a6 Merge pull request #3080 from binaricat/feat/ai-sidebar-provider-model-thinking
feat(ai): unify sidebar provider, model, and thinking pickers
2026-08-20 12:19:45 +08:00
bincxz 71f6f51707 Merge remote-tracking branch 'origin/main' into feat/ai-sidebar-provider-model-thinking
# Conflicts:
#	components/settings/tabs/ai/ProviderConfigForm.tsx
2026-08-20 11:53:43 +08:00
bincxz 0288d1610a fix(ai): skip no-op AI config updates and simplify permission picker
Avoid rewriting thinking/provider maps when values are unchanged so
mounted AI panels do not re-render. Put permission title and short
description on one line and drop the Cursor CLI parenthetical.
2026-08-20 11:52:29 +08:00
bincxz b701df8822 fix(ai): align Catty thinking levels with vendor capabilities
Stop advertising Gemini 3.7 Flash minimal, GPT-5.x-chat reasoning, and
Claude 4.7+ extended thinking. Map GPT-5 Off to minimal, send adaptive
effort on Claude 4.6+, and keep composer Enter on an exact or custom id.
2026-08-20 11:05:12 +08:00
陈大猫 10d7ff9879 Merge pull request #3084 from DeanCen/fix/3083-ai-request-content-length
fix: send Content-Length for AI request bodies instead of chunked encoding
2026-08-20 10:05:58 +08:00
DeanCen a43ddb6804 fix: send Content-Length for AI request bodies instead of chunked encoding
Node's http.request falls back to Transfer-Encoding: chunked when no
Content-Length header is present. Both AI request paths pass the AI SDK's
headers through unchanged, and those headers carry no content-length (in the
browser fetch computes it), so every AI request body went out chunked.

The body is a fully-buffered string at that point, so its length is known.
Send an explicit Content-Length instead, measured with Buffer.byteLength so
multi-byte system prompts are not truncated.

Chunked request bodies are the less interoperable choice: on the plain-HTTP
path an HTTP proxy has to de-frame and re-serialize them, and some fail on
larger bodies. Xray's HTTP inbound drops the connection once the chunked body
exceeds roughly 8KB. Because Catty's system prompt plus tool definitions is
about 48KB, chat against an http:// provider behind a system HTTP proxy failed
with an opaque "socket hang up" while the connection test kept passing, since
that test issues GET /v1/models with no body and never exercises this path.

Adds three regression tests covering the streaming path, byte-accurate length
for multi-byte bodies, and the non-streaming path.

Closes #3083
2026-08-20 00:30:55 +08:00
bincxz 97ef84db06 fix(ai): persist discovered context windows from composer picks
Codex P2: switching to a catalog model dropped context_length, so
Catty budgeted 128k instead of the advertised window. Keep the
metadata on picker models and write it to modelContextWindows.
2026-08-19 23:51:05 +08:00
bincxz 8bce8c15a0 fix(ai): do not display unset thinking as Off
Codex P2: models whose menu includes Off still showed Off for users
with no stored preference, while the request sent no override. Leave
the chip unselected until the user picks a level.
2026-08-19 23:40:09 +08:00
bincxz b7b35afa24 fix(ai): persist explicit Off and omit unset thinking options
Codex P2: missing agentThinkingMap was treated as Off, so GPT-5.1+
got reasoningEffort=none and Gemini 2.5 Flash got thinkingBudget=0
without a user choice. Store Off as an explicit value and leave an
absent preference unset so the provider default is used.
2026-08-19 23:34:28 +08:00
bincxz 958972ffa0 fix(ai): omit Gemini 3 Flash thinking when preference is unset
Codex P2: an empty agentThinkingMap was normalized to Off and then to
minimal, so every Gemini 3 Flash turn overrode the provider default.
Only send thinkingLevel when the user actually picked a level.
2026-08-19 23:29:50 +08:00
bincxz d137662d02 fix(ai): treat gpt-5-chat models as non-reasoning
Codex P2: gpt-5-chat-latest matched the gpt-5 heuristic and received
reasoningEffort, which the OpenAI chat-only variants reject. Exclude
gpt-5-chat* from the thinking chip and request options.
2026-08-19 23:24:35 +08:00
bincxz 10ca915370 fix(ai): include Catty thinking map in settings sync
Codex P2: agentThinkingMap was persisted locally but omitted from
collect/apply sync, so backups and other devices restored provider
and model without the chosen reasoning level.
2026-08-19 23:18:50 +08:00
bincxz 33dc249d74 fix(ai): do not Enter-select custom IDs on external agents
Codex P2: hiding the custom-model row still left Enter falling back
to the typed query, which send-time validation then discarded. Only
use the query as a custom ID when Catty offers that action.
2026-08-19 23:10:34 +08:00
bincxz b490eb3273 fix(ai): keep unset Catty thinking as provider default
Codex P2: opening the composer on Gemini Pro persisted Low for users
who had never chosen a thinking level. Only remap an explicit stale
value; leave an empty preference unset so the request uses the
provider default.
2026-08-19 23:03:56 +08:00
bincxz 55852918c2 fix(ai): sync composer model prefs across retained panels
Codex P2: pinning or recents in one parked ChatInput only updated that
instance. Subscribe every mounted composer so sibling terminals see
the same recent/pinned list without remounting.
2026-08-19 22:57:21 +08:00
bincxz cc85cc7ebe fix(ai): keep Gemini 3.1 Pro medium and drop 3.0 Pro medium
Codex P2: gemini-3.1-pro-preview supports Medium, but Catty rewrote it
to High. Original Gemini 3 Pro still only has Low/High, so omit Medium
instead of silently converting it.
2026-08-19 22:50:29 +08:00
bincxz 6e99c9c1dc fix(ai): drop stale external-agent pinned models
Codex P2: refreshed CLI catalogs still showed saved IDs that the
catalog no longer contains, then send-time validation swapped them.
Only Catty may keep a custom ID that is not in the live list.
2026-08-19 22:45:11 +08:00
bincxz 1dac363d84 fix(ai): discover Catty models from preset Base URL when omitted
Codex P2: a built-in provider with no stored baseURL never listed
live models even though chat still used the preset host. Fall back to
PROVIDER_PRESETS.defaultBaseURL before probing /models.
2026-08-19 22:38:33 +08:00
bincxz 4eddbfab22 fix(ai): hide CLI custom models and count thinking budget once
Codex P2: external-agent pickers advertised a custom ID that send-time
validation ignored. Offer Use custom model only for Catty providers.

Codex P2: the thinking budget was added to both compaction max output
and reservedTokens, so it was subtracted twice. Keep it only on
compaction max output.
2026-08-19 22:29:33 +08:00
bincxz 54d92fb326 fix(ai): reserve Anthropic thinking budget in Catty compaction
Codex P2: medium/high thinking adds 10k/20k to the SDK max_tokens,
but pre-turn/step compaction still reserved only the 4,096 default.
Fold the selected thinking budget into output reserve and compaction
max output without changing the streamed maxTokens field.
2026-08-19 22:24:05 +08:00
bincxz 73a9669bf7 fix(ai): hide Anthropic thinking on models without extended thinking
Codex P2: Claude 3 Haiku still offered Low/Medium/High and would send
an enabled thinking budget. Gate the chip and the provider option on
Claude 3.7 / 4+ model ids. Also memoize Catty thinking levels.
2026-08-19 22:15:34 +08:00
bincxz 5971e03ffe fix(ai): drop stale thinking levels and hide Gemini Pro Off
Codex P2: switching away from Gemini 3 Flash left `minimal` selected
on models that do not advertise it. Persist a valid level for the new
model.

Codex P2: Gemini 3 Pro and 2.5 Pro cannot disable thinking, so Off
was a lie. Those menus only offer low/medium/high.
2026-08-19 22:10:09 +08:00
bincxz 3123c33914 fix(ai): bust model cache on key change and label Gemini minimal
Codex P2: catalog cache ignored API key rotation, so a replaced
credential could keep the previous account's models. Fingerprint the
stored secret in the cache key.

Codex P2: Gemini 3 Flash cannot turn thinking off; Off was sending
thinkingLevel=minimal. Surface Minimal on the chip instead of Off.
2026-08-19 22:02:03 +08:00
bincxz 90e3f121ee fix(ai): send none only on GPT-5.1+ and honor Cursor effort params
Codex P1: o3/o4-mini/gpt-5 reject reasoning_effort=none. Only GPT-5.1+
get that Off mapping; older reasoning models omit the field.

Codex P2: Cursor ModelListItem.parameters.effort.values is the catalog
capability list. Use it when present instead of fabricating fallbacks
or dropping the thinking chip on unknown models.
2026-08-19 21:53:24 +08:00
bincxz be63983693 fix(ai): hide Catty thinking chip on non-reasoning models
Codex P2: OpenAI-style models such as gpt-4o rejected reasoning_effort
when the chip always offered Off/Low/Medium/High. Gate the chip and
the provider option on known reasoning model ids, and send explicit
none when Off is selected on a reasoning model.
2026-08-19 21:45:15 +08:00
bincxz ad16f19268 fix(ai): tighten picker chrome and align provider action buttons
Remove the provider divider and search-field outline in the sidebar
model picker. Size Save/Test/Cancel to the same h-8 box without
changing the global Button styles.
2026-08-19 21:32:18 +08:00
bincxz 77cb55315a fix(ai): keep Ollama Cloud API keys and append /v1
The Ollama preset always rewrote OpenAI-compat keys to the literal
"ollama" dummy. That is correct for local servers with no key, but it
replaced the IPC placeholder used for Cloud, so chat sent
Authorization: Bearer ollama and 401ed. Only fill the dummy when no key
is configured, and normalize a bare ollama.com origin to /v1.
2026-08-19 20:45:58 +08:00
bincxz 54647b2f9a fix(ai): clamp Gemini 3 Pro levels and union Cursor fallbacks
Omit Gemini 3 Pro Off instead of sending unsupported minimal, clamp
medium to high, and merge advertised Cursor efforts with the fallback
table so CLI-listed gpt-5 still gets a thinking chip.
2026-08-19 18:34:26 +08:00
bincxz f3d5e1e88f fix(ai): omit 2.5 Pro Off budget and keep CodeBuddy picker unsuffixed
Gemini 2.5 Pro rejects thinkingBudget 0, so Off omits the config.
Picking a CodeBuddy model without a chip level no longer writes /medium.
2026-08-19 18:25:50 +08:00
bincxz 2f96b86fed fix(ai): keep CodeBuddy settings effort and Gemini 2.5 budgets
Do not rewrite bare CodeBuddy model ids to /medium, fail closed when
Google has no model id, and send Gemini 2.5 thinkingBudget instead of
Gemini 3 thinkingLevel.
2026-08-19 18:11:41 +08:00
bincxz b29f74b925 feat(ai): wire Cursor, CodeBuddy, and Gemini thinking chips
Map Cursor effort variants and CodeBuddy SDK effort onto the shared
sidebar thinking chip, and send Gemini thinkingConfig for Catty
models that look like they support it.
2026-08-19 17:50:16 +08:00
bincxz 3d154ca235 fix(ai): switch providers from a one-line submenu
Keep the composer model menu as a single-column list. Provider
switching now drills in from the header row instead of a left rail.
2026-08-19 17:35:44 +08:00
bincxz 4acf254f72 feat(ai): unify sidebar provider, model, and thinking pickers
Give Catty a two-column provider/model menu in the composer, add search
plus recent/pinned models, and move thinking effort onto its own chip
so Claude, Codex, Grok, and Catty share the same compact controls.

Closes #2137
Closes #2637
Closes #1884
2026-08-19 17:23:06 +08:00
陈大猫 69af65fd02 Merge pull request #3079 from binaricat/feat/3078-openai-responses-api
feat(ai): add optional OpenAI Responses API format
2026-08-19 16:22:18 +08:00
bincxz fdecf652bf feat(ai): add optional OpenAI Responses API format
Closes #3078

OpenAI-compatible providers always used Chat Completions. Some
relays cache poorly on that path. Add a per-provider Responses
opt-in so Catty can call /v1/responses while keeping Chat
Completions as the default.
2026-08-19 16:10:20 +08:00
陈大猫 76f3da2396 Merge pull request #3075 from binaricat/feat/3073-sftp-owner-column
feat(sftp): show file owner column in the SFTP list
2026-08-19 15:16:30 +08:00
bincxz 31d3f76c32 fix(sftp): keep owner when normalizing tree directory listings
Codex review: expanded tree rows rebuild RemoteFile entries without
copying owner, so child panes showed "--" even when the bridge had it.
2026-08-19 15:10:45 +08:00
bincxz 0c4360808a fix(sftp): use symlink entry owner and ACL longname markers
Codex review: read the local directory entry owner via lstat for
symlinks, and accept ls-style +/@/. mode suffixes when parsing
longname usernames.
2026-08-19 14:50:41 +08:00
陈大猫 2a0b0cdeb0 Merge pull request #3070 from binaricat/fix/encrypted-putty-ed25519-ppk
fix(ssh): decrypt encrypted PuTTY ed25519 PPK keys
2026-08-19 14:46:06 +08:00
陈大猫 c98eb84637 Merge pull request #3068 from binaricat/fix/3064-windows-tray-panel-size
fix(tray): place Windows tray panel at designed size next to the icon
2026-08-19 14:45:22 +08:00
bincxz 7b8c944510 feat(sftp): show owner column in the file list
Surface the remote file owner in the SFTP list and tree, using the
existing toggleable column menu. Parse the username from SFTP longname
or ls output, then fall back to uid when a name is unavailable.

Closes #3073
2026-08-19 14:39:17 +08:00
bincxz 3b7c3c0e82 fix(ssh): validate PPK Argon2 limits before runtime check
Malformed work factors must fail with the limits error even when
crypto.argon2Sync is missing, so CI on Node 22 still rejects crafted
headers instead of reporting an unsupported runtime.
2026-08-19 14:32:47 +08:00
bincxz 5ef583ed59 fix(ssh): keep converted PPK unlocked and harden v3 fixtures
Check encryption on the prepared key so SFTP/jump hops do not prompt
again after a PPK is rewritten to OpenSSH. Build Argon2-limit tests
from stub headers so they do not need crypto.argon2Sync. Keep the
empty HMAC key for unencrypted PPK v3, matching PuTTYgen.
2026-08-19 14:28:06 +08:00
bincxz d8c4e6934f fix(tray): drop CSS shadow that double-composites on macOS
The overlay window already uses native hasShadow. CSS shadow-lg on the
transparent rounded card paints a second halo, which macOS then
composites as an extra outline under the panel.
2026-08-19 14:23:41 +08:00
陈大猫 a3e8ab8d71 Merge pull request #3074 from binaricat/fix/3072-never-auto-lock-tray
fix(app-lock): skip tray hide lock when timeout is never
2026-08-19 14:18:45 +08:00
bincxz a08b5fbcd0 fix(ssh): cap PPK v3 Argon2 work factors
Reject non-positive or unbounded memory/passes/parallelism (and
malformed salt) before argon2Sync so a crafted PPK cannot stall the
Electron main process.
2026-08-19 14:18:28 +08:00
bincxz 2c009b3ba4 fix(app-lock): skip tray hide lock when timeout is never
Closes #3072

Close-to-tray and app-hide always requested a background lock whenever
app lock was enabled. "Never lock automatically" only disabled the idle
timer, so clicking X on Windows still asked for the password on reopen.

Honor timeoutMinutes === 0 for background locks. Startup and manual
locks still apply. Setting copy now says Never skips both.
2026-08-19 14:14:13 +08:00
bincxz fee3662e82 fix(tray): trust event bounds over cursor for keyboard tray activation
Codex P2: a keyboard or accessibility activation can fire with valid
event bounds while the pointer sits on another monitor. Keep those
bounds for display and placement; only reject stale tray.getBounds()
when it disagrees with the cursor.
2026-08-19 14:13:07 +08:00
陈大猫 adf0344f73 Merge pull request #3067 from binaricat/fix/3065-lock-system-context-menu
fix(app-lock): hide native window menu while locked
2026-08-19 14:03:45 +08:00
陈大猫 4d98289f96 Merge pull request #3071 from binaricat/fix/3069-settings-search-app-lock
fix(settings): include App Lock in settings search
2026-08-19 14:02:46 +08:00
bincxz 4bf569a727 fix(settings): include App Lock in settings search
Closes #3069

App Lock lives on the System tab but was never added to the
settings search catalog, so queries such as "应用锁" and
"App Lock" returned no matches. Index the section and wire a
jump anchor so search can open it.
2026-08-19 13:59:28 +08:00
bincxz 05bed3c72c fix(ssh): decrypt encrypted PuTTY ed25519 PPK keys
ssh2 only parses PPK v2 RSA/DSA, so an encrypted PuTTYgen ed25519
key failed even with the correct passphrase. Convert PPK v2/v3
(Ed25519, ECDSA, RSA) to OpenSSH PEM before handing the key to ssh2.
2026-08-19 13:58:00 +08:00
bincxz 32ede64554 fix(tray): place Windows tray panel at designed size next to the icon
Windows right-click ignored the event bounds Electron already provides
and reused tray.getBounds() / win.getBounds(), which often report y=0
or a blown-up size. Always set 360x520 and anchor above a bottom
taskbar (or below a top one), falling back to the cursor when bounds
are stale.

Closes #3064
2026-08-19 13:52:56 +08:00
bincxz 8b9685e680 fix(app-lock): hide native window menu while locked
Windows shows the system title-bar menu on right-click in drag regions.
The lock overlay is a drag region so the window stays movable, which also
exposed Minimize/Maximize/Close. Suppress BrowserWindow system-context-menu
while locked so the lock screen cannot quit the app.

Closes #3065
2026-08-19 13:36:33 +08:00
陈大猫 1a55378b44 Merge pull request #3060 from binaricat/automation/nix-release-v1.1.81
Update Nix release metadata for v1.1.81
2026-08-19 10:53:37 +08:00
github-actions[bot] b36dbc10ec Update Nix release metadata for v1.1.81 2026-08-19 02:48:08 +00:00
陈大猫 81dce5a3e7 Merge pull request #3059 from binaricat/fix/app-lock-window-drag 2026-08-19 10:24:15 +08:00
bincxz 4e794d4af1 fix(app-lock): keep window drag region on lock overlay
The lock screen is a full-window overlay, so it covers the title-bar
drag region and is the only chrome on a locked first paint. Mark the
backdrop as app-drag and keep unlock controls app-no-drag.

Closes #3058
2026-08-19 10:18:28 +08:00
陈大猫 1205ef37ae Merge pull request #3056 from binaricat/codex/fix-grok-reasoning-effort
fix(ai): support Grok Build reasoning effort
2026-08-19 09:39:48 +08:00
陈大猫 f7d4cc14c2 Merge pull request #3055 from binaricat/codex/fix-sftp-bookmark-popover-close
fix(sftp): close bookmark popover after path selection
2026-08-19 09:39:23 +08:00
bincxz d36d4f86b3 fix(ai): preserve legacy Grok model selections 2026-08-19 00:34:19 +08:00
bincxz 757fbcfd83 fix(ai): default Grok reasoning selections 2026-08-19 00:29:07 +08:00
bincxz e0fdf48395 fix(ai): preserve Grok legacy effort choices 2026-08-19 00:20:57 +08:00
bincxz 63f44c4ac9 fix(ai): harden Grok model discovery 2026-08-19 00:15:12 +08:00
bincxz 64ba59db10 fix(ai): add Grok reasoning effort selection 2026-08-19 00:02:09 +08:00
bincxz 1617ef8956 fix(sftp): close bookmarks after path selection 2026-08-18 23:49:17 +08:00
陈大猫 8ee1cda924 Merge pull request #1609 from Ryanisgood/feature/app-lock-screen
Feat: 添加应用锁屏功能
2026-08-18 20:57:42 +08:00
bincxz ea91418f4e fix(app-lock): wait for locked startup readiness 2026-08-18 20:48:21 +08:00
bincxz e6c46f8198 fix(app-lock): defer protected actions until unlock 2026-08-18 20:45:29 +08:00
bincxz 8eaab92d31 feat(app-lock): prioritize system unlock flow 2026-08-18 20:22:34 +08:00
bincxz 370b57fa67 style(app-lock): simplify password field 2026-08-18 19:43:54 +08:00
bincxz 8562a26315 style(app-lock): move lock icon into password field 2026-08-18 19:42:07 +08:00
bincxz 0ac5c728b2 style(app-lock): add lock badge to logo 2026-08-18 19:39:43 +08:00
bincxz ff40b4fcff refactor(app-lock): align settings with modal flow 2026-08-18 19:36:52 +08:00
bincxz 0ac30b5049 fix(app-lock): harden lock boundaries 2026-08-18 17:24:46 +08:00
bincxz 20c4c7791f Merge remote-tracking branch 'origin/main' into codex/pr-1609-hardening 2026-08-18 16:33:20 +08:00
bincxzandCursor d422a27715 Merge origin/main into feature/app-lock-screen
Reconcile the app-lock feature with main's refactored renderer
architecture (publishers/hosts/AppShell) and main-process changes:

- index.tsx/App.tsx: AppLockGate keeps ownership of useSettingsState and
  useAppLockState; new AppLockRuntimePublisher publishes the runtime via
  appRuntimeBridge; SettingsPublisher now binds the gate-owned settings.
- AppSideEffects: defer deep links, tray port-forward toggles, global
  hotkeys and window close commands while locked; flush on unlock.
- main.cjs: keep fresh-session re-lock before main's reusable-window
  fast path; before-quit guard delegates to handleBeforeQuit with main's
  async plugin-shutdown commitQuit and pending-update cancellation.
- globalShortcutBridge: keep locked-state tray redaction on top of
  main's deferred tray panel show logic.
- preload: adopt main's lastPayload replay for terminal popup config
  (superset of the gate-delay replay fix).
- vite config: keep dev CSP plugin, adopt main's warn-only stale
  optimize-dep handling; electron-builder: keep Windows Hello helper
  extraResource with main's CLI-controlled arch targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:30:30 +08:00
陈大猫 9eb548a778 Merge pull request #3052 from binaricat/fix/3051-ai-stream-thinking-timeout
fix(ai): do not abort active thinking streams at 2 minutes
2026-08-18 15:56:43 +08:00
bincxz 2c73442a87 fix(ai): do not abort active thinking streams at 2 minutes
The HTTP stream layer used a hard 120s total deadline, so long
reasoning that was still emitting tokens got cut off. Re-arm a
2-minute idle timer on incoming bytes and keep a 30-minute hard cap
for runaway streams.

Closes #3051
2026-08-18 15:53:08 +08:00
陈大猫 4913a043da Merge pull request #3050 from binaricat/fix/3049-mcp-omitted-arguments
fix(mcp): accept omitted arguments for no-arg tools
2026-08-18 15:04:52 +08:00
bincxz ba88ea7e4f fix(mcp): accept omitted arguments for no-arg tools
MCP allows clients to omit tools/call arguments or send {}. Empty Zod
object schemas rejected the omitted case, so external agents could not
call get_environment and other no-arg tools.

Closes #3049
2026-08-18 14:59:22 +08:00
陈大猫 1deea15e0f Merge pull request #3048 from binaricat/fix/3046-focus-hidden-pane-full-size
fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
2026-08-18 14:48:30 +08:00
bincxzandCursor d920d076d4 fix(workspace): hand focus to a remaining session when closing the focused pane
closeSessionWorkspaceLayoutState pruned the workspace tree but left
focusedSessionId pointing at the closed session. In a focus-mode workspace no
pane then qualified as visible, so the continuously rendered hidden panes all
painted on top of each other (reported in #3046 follow-up). Reassign focus to
a remaining session and drop closed ids from focusSessionOrder, matching the
detach path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:34:23 +08:00
bincxzandCursor 95606cb62c fix(terminal): drop stale full-size cache while a pane is split-visible
A pane that was once full-size kept those pixels in lastVisiblePaneSizeRef
through split view; after a viewport resize, switching back to focus pinned
the hidden pane (and its PTY) at the stale size. Split-visible panes now
invalidate the cache so hidden full-size layouts re-measure the current area.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:18:07 +08:00
bincxzandCursor 083ee55455 fix(terminal): never cache split-rect sizes as the hidden pane pinned size
Switching a workspace from split view to focus view left each newly hidden
pane pinned at the split fragment size cached in lastVisiblePaneSizeRef, so
the background PTY stayed narrow and the \r progress spam from #3046 could
still occur. Only full-size layouts feed the hidden-pane pinned size now.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:11:43 +08:00
陈大猫andCursor c4edefdbe4 feat(terminal): pass Esc+. through for readline yank-last-arg (#3047)
* feat(terminal): pass Esc+. through for readline yank-last-arg

Autocomplete was swallowing Escape, and macOS Option+. typed ≥ instead of Meta-., so the standard shell shortcut never reached bash/zsh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): allow Shift when Esc+_ yanks the last argument

Underscore is Shift+Minus, so rejecting all shiftKey follow-ups dropped M-_ after autocomplete dismissed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep Esc+_ armed across the Shift keydown

Typing underscore emits Shift before Minus. Clearing the Meta prefix on that modifier event dropped M-_ after autocomplete dismiss.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): do not treat Esc+. as typed input

Yank-last-arg rewrites the shell line. Walking the ESC+. bytes appended a literal period to the command buffer and left autocomplete's typed buffer marked reliable.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:59:56 +08:00
陈大猫andCursor b7325218fb feat(ssh): accept PuTTY-style CLI args from bastion hosts (#3044)
* feat(ssh): accept PuTTY-style CLI args from bastion hosts

Reuse the existing ssh:// and telnet:// deep-link path so PAM products that launch putty.exe -ssh user@host -P port -pw password can point at Netcatty instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): consume operands of ignored PuTTY flags

Unknown value-taking options such as -m and -D were skipped while their next token was treated as a host or port, which could connect the supplied password to the wrong endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject unsupported PuTTY protocols and unknown flags

-raw/-rlogin/-serial must not fall through to SSH with the supplied password, and valueless switches such as -batch must not consume the following host. Unknown dash flags are now rejected instead of guessed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject missing or empty PuTTY option operands

An empty or trailing -pw was turned into ssh://user:@host, which the deep-link path treats as no password and can then authenticate with a saved vault credential.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): reject unsupported -pwfile, -i, and -hostkey launches

Those options request credentials or a host-key pin this pipeline cannot honor, so connecting with -pw or a vault match would silently change the authentication the caller asked for.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): keep .app/.exe hosts and reject -loghost

Suffix filtering now only covers the Electron script argument, so destinations like prod.app are not dropped. -loghost is rejected because this pipeline cannot honor the logical host-key identity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ssh): keep packaged argv[1] hosts like prod.js

Suffix filtering now applies only after an Electron binary, so a packaged launch can still use a first-position hostname that looks like a script.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:35:27 +08:00
bincxzandCursor 428df79d87 fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
Focus-mode panes are always viewed full-size, but hidden non-focused panes
were laid out at their split rects when hibernate is off (the default). The
continuously rendered xterm and the remote PTY shrank to a 1/N-width
fragment, so \r-refresh progress output (rsync --info=progress2) soft-wrapped
and left one scrollback line per refresh, flooding the terminal when the
host was focused again (#3046). Focus mode now never uses split geometry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:18:46 +08:00
陈大猫andCursor 074acc3bed feat(sftp): add right-click extract for archives (#3042)
* feat(sftp): add right-click extract for archives

Let SFTP list and tree views unpack zip/tar/gz archives in place via remote SSH exec (or local tar/unzip), so users do not need to switch to another file manager.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): keep extract from clobbering files or dying on unzip output

Write gzip/bzip2/xz results to a staging path and replace the destination only after a zero exit. Drain unzip progress instead of treating routine stdout as a hard failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): quote extract cleanup trap and use unique staging files

Remote gzip/bzip2/xz nested a quoted path inside trap, so names with spaces failed before decode. Staging now exclusively creates a unique sibling, and local .gz uses Node zlib.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): refuse single-file extract onto an existing directory

Remote mv would otherwise drop the staging file inside that directory and still report success.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): restore the previous file if Windows extract replace fails

Rename the destination aside before publishing the staging file, instead of deleting it first.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:00:16 +08:00
陈大猫 282d1570cd Merge pull request #3040 from binaricat/fix/3000-enter-strips-previous-line-highlight
fix(terminal): keep previous line highlighted when the Enter chunk carries a bare CR
2026-08-18 10:50:13 +08:00
bincxzandCursor 4cacfac04c fix(terminal): detect start-row mutations by fingerprint instead of enumerating controls
The backtracking regex cannot enumerate every control that homes the
cursor (DECOM, DECCOLM, ...). Snapshot the skipped start row's text before
a newline-prefixed write; if it changed, something climbed back onto that
row and it is restored and repainted with the rest of the range. This
closes the whole class instead of chasing individual sequences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:33:26 +08:00
陈大猫 a2532dde35 Merge pull request #3037 from binaricat/feat/3019-osc-desktop-notifications
feat(terminal): show desktop notifications for OSC 9/777/99
2026-08-18 10:26:50 +08:00
bincxzandCursor d91724e9ce fix(terminal): treat DECSTBM homing as cursor backtracking
Setting a scroll region (`CSI Ps;Ps r`) homes the cursor, so a
newline-prefixed chunk using DECSTBM can land back on the highlighted
starting row like CUU/VPA. Keep the pre-write restore and repaint the
start row for such chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:26:32 +08:00
bincxzandCursor 51a5f8b7aa fix(terminal): treat VPA as cursor backtracking for highlight restoration
`CSI Ps d` positions the cursor on an absolute row and can jump back onto
the highlighted starting row just like CUU/CUP, so newline-prefixed chunks
using VPA must also keep the pre-write restore and repaint the start row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:18:17 +08:00
陈大猫 3bcc01f134 Merge pull request #3038 from binaricat/fix/3007-search-copy-on-select
fix(terminal): do not copy SearchAddon match selections
2026-08-18 10:14:07 +08:00
陈大猫 63da7edd7d Merge pull request #3039 from binaricat/fix/3003-windows-cursor-cli-login-cmd
fix(ai): detect Cursor CLI login on Windows cmd shims
2026-08-18 10:12:34 +08:00
bincxzandCursor fea8444adc fix(terminal): restore the start row when a newline-prefixed chunk climbs back up
A chunk like `\r\n\x1b[1A\rOK\x1b[1B` (multi-line progress redraws) starts
with a line advance but returns to the previous row and partially
overwrites a highlighted keyword. The line-advance exemption skipped the
pre-write restore while the post-write repaint also skipped that row,
leaving the untouched cells with the injected color.

Keep the restore and include the start row in the repaint whenever the
chunk contains sequences that can move the cursor back up (CUU/CPL/CUP,
restore cursor, RI, DECRC).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:12:09 +08:00
bincxz 5083a81681 fix(ai): spawn Cursor CLI via node.exe + index.js on Windows
Codex review: putting the turn prompt on a cmd.exe line hits the
8191-char limit, mishandles quotes/%VAR%, and leaves the Node child
alive after Stop. Resolve cursor-agent.cmd to versions/*/node.exe plus
index.js and spawn that argv with shell:false.

Closes review on #3039
2026-08-18 10:06:49 +08:00
bincxz 545178b201 fix(terminal): abort OSC notify scans on CAN and SUB
xterm ends an OSC unsuccessfully on CAN/SUB. The hibernate scanner
now does the same and keeps the following bytes for replay.
2026-08-18 10:06:35 +08:00
bincxz c75feb0c1a fix(terminal): release copy-on-select when the window blurs
Alt-tab drops the matching mouseup in Electron. Release the gesture
on window blur so a later SearchAddon select cannot stick.
2026-08-18 10:05:21 +08:00
bincxzandCursor 69c26101cd fix(terminal): keep previous line highlighted when Enter chunk carries a bare CR
A write chunk that starts with a line advance (bash 5.1+ fuses the echoed
newline with the bracketed-paste `\x1b[?2004l\r`, command output, and the
next prompt) restored the previous prompt row's highlight before writing,
while the post-write repaint deliberately skips that row. The keyword
highlight on the command line vanished until a background catch-up ran.

Only treat a chunk as an in-place rewrite of the cursor row when it does
not begin with a line advance: once the cursor leaves the row, later CR/EL
sequences can only touch rows the post-write repaint already covers.

Fixes #3000

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:03:01 +08:00
bincxz 9b0cbf0a4c fix(terminal): abort interrupted OSC notify scans
Address the second Codex pass on #3037. A non-ST ESC now ends an
in-flight OSC 9/777/99 the same way xterm does, so hibernated
replay keeps the following CSI/text. OSC 99 fragments are also
concatenated raw so kitty can split on whitespace.
2026-08-18 10:01:26 +08:00
bincxz f44e200874 fix(terminal): scope Select All copy-on-select pulse to one pane
A split workspace must not treat a sibling SearchAddon match as a
user selection when Select All runs in another terminal.
2026-08-18 10:00:53 +08:00
bincxz 2f701c1605 fix(ai): detect Cursor CLI login on Windows cmd shims
Settings → AI → Cursor Check ran `cursor-agent status` via execFileSync
on the path from `where`, which is a .cmd installer shim. Node cannot
spawn .cmd files directly (EINVAL), so the probe reported logged-out
even when the terminal showed authenticated.

Run the status probe, turns, and model listing through the existing
Windows shim spawn helper, without unwrapping cursor-agent.cmd to
node.exe (that would drop index.js). Also accept status JSON wrapped
in cmd noise.

Closes #3003
2026-08-18 09:56:40 +08:00
bincxz d7f12834df fix(terminal): keep Select All and touchcancel on copy-on-select
User-invoked Select All / Select Word now pulse the gesture so they
still copy. touchcancel releases the same way as touchend so an
interrupted touch cannot leave SearchAddon able to overwrite the
clipboard.
2026-08-18 09:56:10 +08:00
bincxz 3f919955fa fix(terminal): honor OSC notify policy while hibernated
Address Codex review on #3037. Disabled mode no longer marks tab
activity. Hidden hibernated sessions now scan incoming chunks for
OSC 9/777/99 so Codex can still raise a toast before the tab wakes.
2026-08-18 09:53:42 +08:00
bincxz 1fc22d4cad fix(terminal): release copy-on-select after late contextmenu
A post-mouseup contextmenu was re-arming the gesture with no later
pointer-up, so SearchAddon could keep copying. Pulse contextmenu and
observe it in document capture so tmux/vim select-word still counts.
2026-08-18 09:51:57 +08:00
bincxz 647970f697 fix(terminal): do not copy SearchAddon match selections
Copy-on-select listened to every xterm selection change. SearchAddon
selects the active match on find and again 200ms after resize/write, so
searching for a term then selecting other text left the search term on
the clipboard (issue #3007).

Only write the clipboard after a user pointer gesture on the terminal.
2026-08-18 09:34:41 +08:00
bincxz 2a52837657 feat(terminal): show desktop notifications for OSC 9/777/99
Closes #3019

Codex and other agents emit OSC 9 when a turn completes or approval
is needed, but Netcatty ignored those sequences. Register OSC 9, 777
notify, and kitty OSC 99 handlers, map them to Electron notifications,
and reuse tray focus so a click jumps back to the emitting session.
Ignore ConEmu OSC 9;n progress sequences so they do not become toasts.
2026-08-18 09:28:11 +08:00
陈大猫 d1912f539a Merge pull request #3036 from binaricat/fix/3023-copy-wrap-space
fix(terminal): keep wrap-boundary spaces when copying
2026-08-18 09:27:54 +08:00
bincxz 7a0443d39a fix(terminal): keep wrap-boundary spaces when copying
Closes #3023

A long line that wraps exactly on a space loses that space on copy
(hbase shell becomes hbaseshell). xterm.js 6 may serve a no-endColumn
translateToString(true) from its string cache via String.trimEnd(),
which cannot tell a real last-column space from empty-cell padding.
Prompt detection and similar callers populate that cache first, so
both the normalizer and raw getSelection drop the space.

Always pass an exclusive end column so the copy path uses
getTrimmedLength() instead of trimEnd().
2026-08-17 21:21:46 +08:00
陈大猫 dd02279cd3 Merge pull request #3035 from binaricat/fix/3026-history-preview-copy
fix(terminal): copy history preview while vim owns the screen
2026-08-17 21:05:31 +08:00
bincxz 781b8d642d fix(terminal): dismiss history preview when the chord is forwarded
#3026

An empty Ctrl+C copy binding kept the overlay up and still sent SIGINT.
The same happened for an unbound Ctrl/Cmd+A and for context-menu paste.
Keep the preview only when copy/select-all actually consume the chord,
and hide it before ordinary paste.
2026-08-17 20:49:52 +08:00
bincxz ff1f9efe5f fix(terminal): copy preview text after xterm loses focus
#3026

Dragging on the overlay can blur xterm, so native copy never hit the
container listener and the raw wrapped rows went to the clipboard.
Listen on document while the preview is open. Also keep a selected
hard newline at a row boundary instead of dropping it.
2026-08-17 20:45:52 +08:00
bincxz 19e89934c1 fix(terminal): keep preview copy and paste-selection consistent
#3026

Select All built an element-boundary range, so wrap joining never ran
and copied extra newlines. Context-menu paste selection also left the
overlay up, unlike the keyboard path. Select the text node, accept
element-boundary offsets, and dismiss the preview before pasting.
2026-08-17 20:38:58 +08:00
bincxz 137deac897 fix(terminal): dismiss history preview on click and join wraps
#3026

The overlay had to keep pointer events so history could be copied, but
then every later click stayed on the preview. A click without a drag
now closes it and restores terminal focus. Copy also joins overlay
rows that xterm marked as soft-wrapped, so a long command does not
pick up extra newlines.
2026-08-17 20:34:22 +08:00
bincxz f4ff04897e fix(terminal): treat history preview as a selection for Ctrl+C
#3026

Urgent interrupt still asked only xterm for a selection. With history
preview text selected, Ctrl+C therefore sent SIGINT into vim before the
copy path ran. Use the same copyable-selection flag for interrupt,
shortcut passthrough, and Kitty broadcast.
2026-08-17 20:28:11 +08:00
bincxz b6b433dc88 fix(terminal): copy history preview while vim owns the screen
#3026

Shift+scroll already overlays the normal-buffer history on the
alternate screen, but the overlay ignored pointer events and any
mousedown or copy chord dismissed it. Selection now stays on the
preview so the usual copy shortcuts, context menu, and copy-on-select
can take that text without leaving vi.
2026-08-17 19:26:59 +08:00
陈大猫 2136f43b32 Merge pull request #3030 from binaricat/fix/2923-term-sshd-idle-park
fix(ssh): do not idle-park TERM-SSHD transports
2026-08-17 19:11:27 +08:00
bincxz dc7477804f fix(ssh): confirm Copy Tab reuse after the source shell closes
#2923

Copy Tab pins the source transport across preflight. If that shell
closes before the queued channel opens, pendingShellReconnectRisk is
already recorded and TERM-SSHD marks the conn non-reusable for shells.
Decide liveness and allowShellReuse at channel-open time so a pinned
captured source cannot accept an immediately exiting session.
2026-08-17 18:22:35 +08:00
bincxz 6719e35909 fix(ssh): confirm reused shells after SFTP-held last close
#2923

Idle park is not the only last-shell state. An SFTP or forward lease
keeps the transport live and records pendingShellReconnectRisk; those
reconnects now run the same liveness settle. Known-bad banners also
refuse later shell reuse on that socket so start() dials fresh instead
of borrowing the dead session again.

Keep the liveness timer referenced so node --test cannot exit while
the settle is still pending.
2026-08-17 18:13:25 +08:00
bincxz be22711041 fix(ssh): do not idle-park TERM-SSHD transports
#2923

QiZhi TERM-SSHD accepts a new session channel on a parked connection
and immediately exits 0, so reconnect after close flashes the tab
shut. 1.1.80 skipped PID discovery on that path but still reused the
parked transport.

Known-bad banners now end the transport instead of parking. Idle-park
reconnects on unknown banners wait for the new shell to stay open; an
immediate exit discards the transport, denylists the endpoint, and
falls back to a fresh dial. Copy Tab and live multiplex are unchanged.
2026-08-17 18:01:10 +08:00
陈大猫 40d43e88ba Merge pull request #3029 from binaricat/fix/3025-snippet-shortcut-conflict
fix(snippets): ignore disabled app shortcuts in shortkey conflicts
2026-08-17 17:59:50 +08:00
bincxz 0222736349 fix(snippets): ignore disabled app shortcuts in shortkey conflicts
When the keyboard shortcut scheme is off, snippet recording still
treated default Mac and PC bindings as occupied. Conflict checks now
use only bindings that can actually fire, and the error names the
conflicting action.

Closes #3025
2026-08-17 17:55:06 +08:00
陈大猫 7333ee1d11 Merge pull request #3027 from binaricat/fix/3020-sse-data-optional-space
fix(ai): accept SSE data lines without a space after the colon
2026-08-17 17:31:28 +08:00
bincxz 4291df8942 fix(ai): accept SSE data lines without a space after the colon
Older AxonHub 0.9 and some intranet OpenAI-compat proxies emit
`data:{json}` instead of `data: {json}`. The stream parser required
the space, so chat chunks were dropped while the model-list probe
still succeeded.

Parse `data:` per the EventSource spec (optional single space) and
cover both the line and end-of-stream flush paths.

Closes #3020
2026-08-17 17:29:09 +08:00
github-actions[bot]andgithub-actions[bot] 40be3b1582 chore: update star history charts [skip ci] (#3009)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 00:05:42 +00:00
陈大猫 586a86b781 Merge pull request #3008 from difagume/feature/i18n-spanish 2026-08-16 20:57:57 +08:00
Diego Fabricio 275f752642 feat(i18n): add neutral Spanish (es) UI locale
- Add complete es message dictionaries (core, vault, terminal, ai,
  systemManager, scripts) covering all en keys with matching placeholders
- Register 'es' in MESSAGES_BY_LOCALE and SUPPORTED_UI_LOCALES
- Extend locale test coverage to include es
2026-08-16 00:27:54 -05:00
陈大猫 e66e4dd26e Merge pull request #2998 from binaricat/automation/nix-release-v1.1.80
Update Nix release metadata for v1.1.80
2026-08-15 13:09:49 +08:00
github-actions[bot] 0b18e1b366 Update Nix release metadata for v1.1.80 2026-08-14 19:48:37 +00:00
bincxz c85ab87262 fix(app-lock): queue disable/reset; pin system-auth lock version
Put requestDisable/requestReset on the settings mutation queue so concurrent
timeout changes cannot re-enable App Lock after a successful disable. Capture
the runtime lock version before system auth and reject the prompt if the lock
epoch advanced (idle→background re-lock) while the dialog was open.
2026-08-06 07:16:24 +08:00
bincxz 90b05946d0 fix(app-lock): hide tray session details while locked
While the runtime is locked, tray panel/menu data is redacted to empty
sessions and port-forwards so cached host details are not visible.
2026-08-06 07:02:46 +08:00
bincxz 7c5b811c27 fix(app-lock): queue password changes; drop stale system-auth results
Password-change saves go through the settings mutation queue. System unlock
re-checks locked state after the OS prompt so a re-lock during the dialog
cannot unlock from a stale auth success.
2026-08-06 06:50:32 +08:00
bincxz 851e772026 fix(app-lock): close DevTools on lock; serialize settings; dedupe system unlock
Close open DevTools when locking, queue full RMW settings mutations, and share
one in-flight system-auth prompt across windows.
2026-08-06 06:41:09 +08:00
bincxz 820587b4f5 fix(app-lock): serialize settings writes; fix system-unlock test
Queue app-lock save mutations and use unique temp paths so concurrent
writes cannot ENOENT or clobber. Update the system-unlock toggle test to
match the already-enabled-while-unavailable disable path.
2026-08-06 06:31:13 +08:00
bincxz 81a62b7ae2 fix(app-lock): atomic settings save and block native close while locked
Write app-lock settings via temp file + rename to avoid truncating the
verifier on crash. Gate application-menu Close and window:close IPC while
locked so popups are not destroyed behind the overlay.
2026-08-06 06:21:53 +08:00
bincxz 79603979ef fix(app-lock): count minimized windows as active for fresh-session
isVisible is false for minimized BrowserWindows, which made Dock reopen
re-lock while a minimized main/session window was still open. Treat
isMinimized() as a live session, while still ignoring hidden prewarm
windows.
2026-08-06 06:04:39 +08:00
bincxz 4e6c323559 fix(app-lock): keep started terminal popups mounted across re-lock
Only defer the initial Terminal start while locked. After a popup session
has begun, keep ready true so re-lock does not unmount the live terminal.
2026-08-06 05:47:23 +08:00
bincxz 1e0988d5f0 fix(app-lock): defer terminal popup start until unlocked
Keep forceRenderChildren so popup config IPC still registers while locked,
but do not mark the popup ready (or mount Terminal/startup commands) until
allowTerminalStart is true after unlock.
2026-08-06 05:38:20 +08:00
bincxz 01d91f55a7 fix(app-lock): repair nested isAppLockOverlayActive imports
The overlay helper import was accidentally inserted inside open named
import lists, which broke TypeScript parsing. Move it to a top-level
import statement in SnippetsManager and SFTP keyboard shortcuts.
2026-08-06 05:25:09 +08:00
bincxz c7052d6150 test(app-lock): expect optimistic unlock to keep observed version
Align unit tests with the no-version-bump unlock path so concurrent
main-process re-locks can still win via selectPreferredRuntimeAppLockState.
2026-08-06 05:15:33 +08:00
bincxz 0f073702f7 fix(app-lock): do not bump runtime version on optimistic unlock
Optimistic unlock used current.version+1, which can outrank a concurrent
main-process re-lock and make refreshRuntimeState ignore the authoritative
locked state. Keep the observed version so real main updates always win.
2026-08-06 05:15:05 +08:00
bincxz 8a4bec65dc fix(app-lock): topmost overlay and gate remaining capture key handlers
Raise the lock screen to max z-index and hide the context-menu portal while
locked. Centralize isAppLockOverlayActive and gate ChatInput slash picker,
SFTP shortcuts, theme modal, and shortkey/hotkey recorders so earlier
window-capture listeners cannot steal unlock keystrokes.
2026-08-06 05:00:00 +08:00
bincxz 87430cc6fa fix(app-lock): ignore hidden prewarm windows for fresh-session lock
Settings prewarm stays as a hidden BrowserWindow and was blocking
ensureAppLockForFreshSession after the main window closed. Only visible
windows count as a live session for that emptiness check.
2026-08-06 04:49:54 +08:00
bincxz b3ec94b6ef fix(app-lock): gate window key recorders while lock overlay is open
Earlier-registered capture handlers (shortkey/hotkey recorders, snippet
save) run before the overlay's stopImmediatePropagation. Skip those
handlers when [data-app-lock-overlay] is present so password keystrokes
cannot mutate settings behind the lock screen.
2026-08-06 04:39:17 +08:00
bincxz 5967b8fe56 fix(app-lock): count aux windows for fresh session; allow disable unlock
Fresh-session re-lock now considers settings/tray/popup windows via
getAppLockReopenWindows so an open Settings UI is not interrupted.
System unlock toggle stays clickable when already enabled even if the
authenticator is currently unavailable, so users can turn it off.
2026-08-06 04:29:10 +08:00
bincxz 100394dce9 fix(app-lock): block app key handlers before window listeners
While locked, use window capture with stopImmediatePropagation so
shortkey/hotkey recorders never see lock-screen keys, and stop
document bubble so window-level shortcuts (e.g. Snippets save) cannot
run even when focus is on body.
2026-08-06 04:12:23 +08:00
bincxz 81e1f7797d fix(app-lock): stop lock-screen keystrokes reaching app shortcuts
inert does not block keydown bubbling to window listeners. While locked,
stop propagation for keys targeted at the overlay so panel shortcuts
(e.g. Snippets Ctrl/Cmd+S) cannot act behind the lock screen.
2026-08-06 03:48:19 +08:00
bincxz 1d81be94f4 fix(app-lock): emit reopen when showing the tray lock panel
Background-locked tray panel needs reopenSignal so system unlock can
auto-prompt when the tray panel is opened.
2026-08-06 03:33:15 +08:00
bincxz ffb25f81c5 fix(app-lock): block reload and DevTools menu while locked
Native View menu reload/toggleDevTools can bypass the lock overlay.
Gate those actions on app-lock runtime locked state.
2026-08-06 03:15:06 +08:00
bincxz 6d6d9cdf2f fix(app-lock): record activity when enabling from no verifier
Avoid scheduling an immediate idle lock after first-time password setup
when the app has been open longer than the selected timeout.
2026-08-06 03:04:26 +08:00
bincxz dbe1a7469b fix(app-lock): gate native window command-close while locked
Cmd/Ctrl+W from the app menu uses IPC, not keydown; ignore it while the
lock overlay is up so sessions cannot close behind the lock screen.
2026-08-06 02:46:32 +08:00
bincxz a3b4439723 fix(app-lock): redact password verifier hash/salt over IPC
getSettings/broadcasts replace real salt and hash with zeroed placeholders
so renderers keep presence checks without exposing offline verifiers.
2026-08-06 02:37:09 +08:00
bincxz 8c0b9c5a47 fix(app-lock): restore getEffectiveKnownHosts import in App
App still uses the helper for effectiveKnownHosts and sync payload; the
import cleanup left a ReferenceError on main-window render.
2026-08-06 02:22:09 +08:00
bincxz c3dcb5c326 fix(app-lock): raise lock overlay above portaled menus
Popovers/dropdowns use z-999999; lock was only z-200000 so open menus
could remain interactive above the lock screen.
2026-08-06 02:09:42 +08:00
bincxz b6aa0fbfe5 fix(app-lock): show native dirty-quit warning above lock overlay
Renderer toasts sit under AppLockOverlay, so unsaved-editor quit blocks
looked like a no-op while locked. Surface a native dialog above the lock.
2026-08-06 01:59:05 +08:00
bincxz 9942d0825c fix(app-lock): include app-content windows in reopen notifications
Detached session windows are app-content only; without reopenSignal they
never auto-prompt system unlock after a background re-lock.
2026-08-06 01:37:23 +08:00
bincxz 1d11ae4c00 fix(app-lock): mark lock dialog data-state=open for shortcut guards
Mounted shortcut hooks (e.g. SFTP) suppress when a dialog has
data-state=open. Without that marker, keyboard actions could still run
behind the app lock overlay when focus is not on the password field.
2026-08-06 01:18:09 +08:00
bincxz 0d1fd79326 fix(app-lock): keep settings sync in detached session windows
Session windows still need theme/font/language updates from the main
window. Disable only system side effects, not settings IPC/storage sync.
2026-08-06 00:59:20 +08:00
bincxz 2433fbae47 fix(app-lock): mount terminal popup children before lock init
System Manager terminal popups receive config immediately after loadURL.
AppLockGate used to withhold children until async lock state resolved,
so onTerminalPopupConfig could miss the one-shot IPC. Force-render the
popup route and keep the last preload config for late remounts.
2026-08-06 00:49:56 +08:00
bincxz 9b142ebf3f fix(app-lock): broadcast lock state to session app-content windows
Detached #/session-window windows register with registerAsMainWindow:false
and were omitted from app-lock runtime/settings broadcasts, so they stayed
interactive after a lock. Include getAppContentWindows() (deduped) in the
broadcast fan-out and cover session windows in the harness.
2026-08-06 00:35:12 +08:00
bincxz 25d6646ffe fix(app-lock): re-lock on macOS reopen after last window closes
On darwin, closing every window leaves the process alive while app-lock
runtime stays unlocked. Re-apply startup lock when the last window closes
and before creating a fresh main window with no app-content windows.
2026-08-06 00:13:51 +08:00
bincxz 193e5e8251 fix(app-lock): inert re-locked UI; defer tray port-forward until unlock
Mark mounted app children inert/aria-hidden/pointer-events-none while locked,
trap Tab inside the lock dialog, and suppress global hotkeys. Queue tray
port-forward toggles in main (and renderer) until the runtime unlocks.
Dirty-quit focus restore already verified.
2026-08-05 23:55:26 +08:00
bincxz 25cb79f602 fix(app-lock): focus dirty editor windows before aborting quit
When quit is cancelled because a renderer reports unsaved editors,
show and focus those windows first. A tray-hidden or unfocused main
window otherwise only posts a toast the user never sees, so quit
appears to fail silently.
2026-08-05 22:59:45 +08:00
bincxz 9e273aded1 fix(app-lock): keep ssh deep links queued until unlock
Defer first App mount (and rendererReady) for any lock reason so
background/idle locks cannot deliver ssh:// connects behind the overlay.
Wait indefinitely for renderer readiness and re-queue failed deliveries so
startup unlock after the old 15s timeout no longer drops deep links. Queue
renderer-side deep links while already-mounted sessions re-lock.
2026-08-05 22:48:28 +08:00
Zheng Liu 5a3ffe119d Merge upstream/main into feature/app-lock-screen 2026-06-30 09:44:01 +08:00
Zheng Liu 54b30bab7d chore: trigger CI for app lock PR 2026-06-30 09:17:39 +08:00
Zheng Liu 17b6130014 Respect Windows package arch in builder config 2026-06-29 20:55:38 +08:00
Zheng Liu e3b1547d59 Silence MSVC coroutine deprecation for Windows Hello helper 2026-06-29 20:42:31 +08:00
Zheng Liu 61ef47b39b Fix Windows Hello helper MSVC invocation 2026-06-29 20:31:28 +08:00
Zheng Liu b8bb91ec2e Initialize MSVC for Windows package builds 2026-06-29 20:18:52 +08:00
Zheng Liu 90db33c1b4 Fix system app-lock availability handling 2026-06-29 17:10:22 +08:00
Zheng Liu 6c49baf84a feat: add app lock system unlock auto prompt setting 2026-06-24 16:27:43 +08:00
Zheng Liu 46716f177c fix: refine app lock biometric unlock 2026-06-24 15:58:48 +08:00
Zheng Liu 41f8924209 fix: streamline app lock system unlock 2026-06-24 15:38:24 +08:00
Zheng Liu a4b83887f9 fix: initialize msvc for windows hello helper 2026-06-24 15:16:07 +08:00
Zheng Liu 1d3484fe4c fix: validate windows hello helper architecture 2026-06-24 14:59:08 +08:00
Zheng Liu 08032b01f7 fix: package windows hello helper per arch 2026-06-24 14:51:52 +08:00
Zheng Liu 0ea707f70f fix: harden app lock system unlock flow 2026-06-24 14:42:49 +08:00
Zheng Liu f6a49dbf0a feat: add app lock system unlock setting 2026-06-24 14:30:12 +08:00
Zheng Liu ba3e76ea12 test: cover app lock system unlock overlay 2026-06-24 14:27:42 +08:00
Zheng Liu 751aee07f3 feat: expose app lock system unlock state 2026-06-24 14:26:24 +08:00
Zheng Liu 41d3000334 feat: wire app lock system unlock ipc 2026-06-24 14:21:31 +08:00
Zheng Liu 4dcb06d39b feat: add app lock system auth bridge 2026-06-24 14:17:47 +08:00
Zheng Liu 8a49c5609d feat: add windows hello helper packaging 2026-06-24 14:14:44 +08:00
Zheng Liu 38f318bc57 feat: persist app lock system unlock setting 2026-06-24 14:12:40 +08:00
Zheng Liu 564095d28f fix: stabilize app lock ci checks 2026-06-24 09:48:08 +08:00
Zheng Liu 5be97aa5e0 merge: sync upstream main 2026-06-24 09:32:43 +08:00
Zheng Liu c186258abd feat: add app lock reset easter egg 2026-06-24 09:18:08 +08:00
Zheng Liu 86df78ec79 feat: allow disabling idle app lock 2026-06-23 17:15:46 +08:00
Zheng Liu 43f8a39fef chore: simplify app lock setup copy 2026-06-23 17:12:06 +08:00
Zheng Liu 559f2a4260 fix: clarify app lock password lifecycle 2026-06-23 16:59:17 +08:00
Zheng Liu 6571c7847d feat: clarify app lock setup flow 2026-06-23 16:46:42 +08:00
Zheng Liu cc20ca0832 fix: cancel tray reveal stale app lock hide 2026-06-23 16:13:56 +08:00
Zheng Liu 8528ec9801 test: tighten app lock minimize preservation 2026-06-23 15:59:10 +08:00
Zheng Liu a4e8958086 test: cover app lock minimize preservation 2026-06-23 15:56:41 +08:00
Zheng Liu a0581b5382 test: tighten app lock idle activity timing 2026-06-23 15:52:43 +08:00
Zheng Liu 8f35fe4b03 test: cover shared app lock idle activity 2026-06-23 15:49:22 +08:00
Zheng Liu fad39c00ca test: cover app lock password change authority 2026-06-23 15:44:51 +08:00
Zheng Liu ad90aa9854 test: harden app lock recovery regressions 2026-06-23 15:38:17 +08:00
Zheng Liu 984328becf test: extend app lock renderer recovery coverage 2026-06-23 15:05:07 +08:00
Zheng Liu 091ea93984 test: cover app lock renderer runtime flows 2026-06-23 15:01:30 +08:00
Zheng Liu 15b729ea6b fix: recreate crashed window on app lock reopen 2026-06-23 14:49:47 +08:00
Zheng Liu 408908079d feat: unify app lock runtime ownership 2026-06-23 14:48:25 +08:00
Zheng Liu faa683cd17 fix: route app lock bridge through state hook 2026-06-17 15:07:27 +08:00
Zheng Liu 2afefa5260 fix: delay renderer ready until app lock unlocks 2026-06-17 14:22:39 +08:00
Zheng Liu 02388f04b8 fix: gate app routes before startup unlock 2026-06-17 14:13:03 +08:00
Zheng Liu 34d3d7b16d fix: lock app when main window reopens 2026-06-17 13:56:47 +08:00
Zheng Liu 6d262276a7 fix: reschedule app lock idle checks 2026-06-17 13:49:01 +08:00
Zheng Liu b284774a1a feat: add app lock settings UI 2026-06-17 13:45:19 +08:00
Zheng Liu 4d54b12f56 feat: add app lock overlay 2026-06-17 13:40:09 +08:00
Zheng Liu e2c25508a1 feat: add app lock lifecycle state 2026-06-17 13:35:53 +08:00
Zheng Liu b0fafede80 feat: persist app lock settings 2026-06-17 13:27:48 +08:00
Zheng Liu 2072471467 feat: add app lock password verifier 2026-06-17 13:21:38 +08:00
392 changed files with 35566 additions and 1693 deletions

No files matched your search

+12
View File
@@ -71,11 +71,13 @@ on:
- "scripts/linux/**"
- "scripts/mosh-extra-resources.cjs"
- "scripts/nodePtyConptyPatch.cjs"
- "scripts/patch-xterm-macos-column-selection.cjs"
- "scripts/patch-xterm-webgl-atlas.cjs"
- "scripts/rebuildPatchedNodePty.cjs"
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
# A newer run for the same main push or PR cancels older in-progress work.
@@ -220,6 +222,12 @@ jobs:
node-version: 22
cache: npm
- name: Set up MSVC developer command prompt
if: matrix.name == 'windows'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Restore Electron download cache
uses: actions/cache@v6
with:
@@ -237,6 +245,10 @@ jobs:
- name: Install deps
run: npm ci
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
- name: Compile ConPTY test helpers
if: matrix.name == 'windows'
shell: pwsh
+2
View File
@@ -29,6 +29,7 @@
!README.ja-JP.md
!README.md
!README.zh-CN.md
!README.zh-TW.md
!electron-builder.config.cjs
!eslint.config.js
!flake.lock
@@ -115,6 +116,7 @@ coverage
!/build/icons
!/build/installer.nsh
/electron/native/**/build
/electron/bridges/windowsHelloHelper/build/
/release
/out
/public/monaco
+30 -41
View File
@@ -4,6 +4,8 @@ import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { useSettingsChromeStore } from './application/state/settingsChromeStore';
import { I18nProvider } from './application/i18n/I18nProvider';
import type { useSettingsState } from './application/state/useSettingsState';
import type { useAppLockState } from './application/state/useAppLockState';
import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
@@ -12,19 +14,20 @@ import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticat
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { AppShell } from './application/app/AppShell';
import { AppLocalStateProvider } from './application/app/AppLocalState';
import { AppSideEffects } from './application/app/AppSideEffects';
import { SessionPublisher } from './application/app/publishers/SessionPublisher';
import { SettingsPublisher } from './application/app/publishers/SettingsPublisher';
import { VaultPublisher } from './application/app/publishers/VaultPublisher';
import { AppLockRuntimePublisher } from './application/app/publishers/AppLockRuntimePublisher';
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
let rendererReadySent = false;
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
/**
* Bridges the locale out of settings chrome without pulling App into a
@@ -49,29 +52,16 @@ function App() {
);
}
function AppWithProviders() {
/**
* `AppLockGate` (index.tsx) owns `useSettingsState` / `useAppLockState` so the
* lock overlay can render before app children mount, and it owns the
* splash-removal + rendererReady signal (deep links must wait for unlock).
* App forwards the gate's settings instance into SettingsPublisher so the
* runtime slot/context still publish a single settings runtime.
*/
function AppWithProviders({ settings, appLock }: { settings: SettingsState; appLock: AppLockState }) {
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
useEffect(() => {
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
}
if (!rendererReadySent) {
rendererReadySent = true;
netcattyBridge.get()?.rendererReady?.();
}
} catch {
// ignore
}
return () => {
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
};
}, []);
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
@@ -83,24 +73,23 @@ function AppWithProviders() {
useExternalMcpGrantPersister();
return (
<SettingsPublisher
enableSettingsSync={!isPeerSessionWindow}
enableSystemEffects={!isPeerSessionWindow}
>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
<SettingsPublisher settings={settings}>
<AppLockRuntimePublisher appLock={appLock} appLockEnabled={settings.appLockSettings.enabled}>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
</AppLockRuntimePublisher>
</SettingsPublisher>
);
}
+3 -3
View File
@@ -24,7 +24,7 @@
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E6%9C%80%E6%96%B0%E7%89%88%E3%82%92%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89&color=success" alt="最新版をダウンロード">
</a>
</p>
@@ -35,7 +35,7 @@
</p>
<p align="center">
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
</p>
---
@@ -295,7 +295,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
4. ブランチにプッシュ (`git push origin feature/amazing-feature`)
5. Pull Request をオープン
アーキテクチャの概要とコーディング規約については [agents.md](agents.md) を参照してください。
アーキテクチャの概要とコーディング規約については [AGENTS.md](AGENTS.md) を参照してください。
---
+2 -2
View File
@@ -35,7 +35,7 @@
</p>
<p align="center">
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
</p>
---
@@ -296,7 +296,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
See [agents.md](agents.md) for architecture overview and coding conventions.
See [AGENTS.md](AGENTS.md) for architecture overview and coding conventions.
---
+3 -3
View File
@@ -24,7 +24,7 @@
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E4%B8%8B%E8%BD%BD%E6%9C%80%E6%96%B0%E7%89%88&color=success" alt="下载最新版">
</a>
</p>
@@ -35,7 +35,7 @@
</p>
<p align="center">
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.ja-JP.md">日本語</a>
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
</p>
---
@@ -294,7 +294,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
4. 推送到分支 (`git push origin feature/amazing-feature`)
5. 打开一个 Pull Request
查看 [agents.md](agents.md) 了解架构概述和编码规范。
查看 [AGENTS.md](AGENTS.md) 了解架构概述和编码规范。
---
+334
View File
@@ -0,0 +1,334 @@
<p align="center">
<img src="public/icon.png" alt="Netcatty" width="128" height="128">
</p>
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>🔥 AI 驅動的 SSH 用戶端、SFTP 瀏覽器與終端機管理工具 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=%E4%B8%8B%E8%BC%89%E6%9C%80%E6%96%B0%E7%89%88&color=success" alt="下載最新版">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
</a>
</p>
<p align="center">
<a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja-JP.md">日本語</a>
</p>
---
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent —— 你的 IT 維運 AI 夥伴
> 🚀 **用 AI 加速你每天的 IT 維運工作。** Catty Agent 是內建的 AI 助理,它了解你的伺服器、會執行指令,並且透過自然對話完成複雜的多主機作業。
### 🔥 Catty Agent 能做什麼?
- 🚀 **用自然語言管理伺服器** —— 直接說出需求,不必再背指令
- 🔥 **即時伺服器診斷** —— 透過對話檢查狀態、查看日誌、監控資源
- 🚀 **多主機協同作業** —— 同時在多台伺服器上協調任務
- 🔥 **智慧的上下文感知** —— 理解你的伺服器環境,給出對症下藥的回應
- 🚀 **一句話完成複雜操作** —— 建立叢集、部署服務,交代一聲就好
### 🎬 AI 實戰示範
#### 🔥 單一主機 —— 智慧伺服器診斷
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
# 目錄 <!-- omit in toc -->
- [🔥 Catty Agent —— AI 夥伴](#catty-agent)
- [Netcatty 是什麼](#netcatty-是什麼)
- [為什麼選 Netcatty](#為什麼選-netcatty)
- [功能特色](#功能特色)
- [介面截圖](#介面截圖)
- [主視窗](#主視窗)
- [Vault 檢視](#vault-檢視)
- [分割終端](#分割終端)
- [支援的發行版](#支援的發行版)
- [快速開始](#快速開始)
- [建置與打包](#建置與打包)
- [技術堆疊](#技術堆疊)
- [參與貢獻](#參與貢獻)
- [貢獻者](#貢獻者)
- [Star 紀錄](#star-紀錄)
- [授權條款](#授權條款)
---
<a name="netcatty-是什麼"></a>
# Netcatty 是什麼
**Netcatty** 是一套現代化的 SSH 用戶端與終端機管理工具,支援 macOS、Windows 與 Linux,專為需要有效率管理多台遠端伺服器的開發者、系統管理員與 DevOps 工程師而設計。
- **Netcatty 是** PuTTY、Termius、SecureCRT 與 macOS Terminal.app 在 SSH 連線上的替代方案
- **Netcatty 是** 一套功能完整的 SFTP 用戶端,具備雙窗格檔案瀏覽器
- **Netcatty 是** 一個終端機工作區,支援分割窗格、分頁與工作階段管理
- **Netcatty 支援** SSH、本機終端、Telnet、Mosh 與序列埠(Serial)連線(視環境而定)
- **Netcatty 不是** Shell 的替代品 —— 它是透過 SSH/Telnet/Mosh 或本機/序列埠工作階段連上 Shell
---
<a name="為什麼選-netcatty"></a>
# 為什麼選 Netcatty
如果你經常同時照顧一整批伺服器,Netcatty 是為了速度與順暢而設計的:
- **以工作區為核心** —— 分割窗格 + 分頁 + 工作階段還原,適合長時間掛著的工作流程
- **Vault 整理** —— 網格/清單/樹狀檢視,搭配快速搜尋與順手的拖曳操作
- **認真做的 SFTP** —— 內建編輯器 + 拖放 + 流暢的檔案操作
---
<a name="功能特色"></a>
# 功能特色
### 🗂️ Vault
- **多種檢視** —— 網格/清單/樹狀
- **快速搜尋** —— 迅速找到主機與群組
### 🖥️ 終端機工作區
- **分割窗格** —— 水平與垂直分割,方便多工
- **工作階段管理** —— 多條連線並排處理
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
- **就地編輯** —— 內建編輯器,快速修改檔案
### 🎨 個人化
- **自訂主題** —— 依你的喜好調整外觀
- **關鍵字高亮** —— 自訂終端輸出的高亮規則
---
<a name="介面截圖"></a>
# 介面截圖
<a name="主視窗"></a>
## 主視窗
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-檢視"></a>
## Vault 檢視
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分割終端"></a>
## 分割終端
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
<a name="支援的發行版"></a>
# 支援的發行版
Netcatty 會自動辨識已連線主機的作業系統,並顯示對應圖示:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
<img src="public/distro/debian.svg" width="48" alt="Debian" title="Debian">
<img src="public/distro/centos.svg" width="48" alt="CentOS" title="CentOS">
<img src="public/distro/fedora.svg" width="48" alt="Fedora" title="Fedora">
<img src="public/distro/arch.svg" width="48" alt="Arch Linux" title="Arch Linux">
<img src="public/distro/alpine.svg" width="48" alt="Alpine" title="Alpine">
<img src="public/distro/amazon.svg" width="48" alt="Amazon Linux" title="Amazon Linux">
<img src="public/distro/redhat.svg" width="48" alt="Red Hat" title="Red Hat">
<img src="public/distro/rocky.svg" width="48" alt="Rocky Linux" title="Rocky Linux">
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="快速開始"></a>
# 快速開始
### 下載
到 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下載適合你平台的最新版本。
| 作業系統 | 支援情況 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
也可以到 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 瀏覽所有版本。
### 程式碼簽章與隱私
Netcatty 正在申請 SignPath Foundation 的開源專案計畫。通過之後,適用的 Windows
發行檔會採用 **Free code signing provided by SignPath.io, certificate by SignPath Foundation**。
詳情請見[程式碼簽章政策](CODE_SIGNING_POLICY.md)與[隱私權政策](PRIVACY.md)。在申請與介接完成前,
Windows 發行檔可能仍未簽章。
> **Windows 免安裝版的資料存放:** 先結束 Netcatty,然後在 `Netcatty.exe`(zip 版)或免安裝版啟動檔旁邊建立一個名為 `data` 的資料夾。下次啟動時,Netcatty 就會把設定檔存在那裡。已儲存的密碼與私密金鑰仍受建立它們的那個 Windows 使用者帳戶保護,所以把資料夾搬到另一台電腦或另一個 Windows 帳戶後,這些資訊必須重新輸入。
> **在 Windows 用 Netcatty 開啟資料夾:** 安裝版會在檔案總管的資料夾右鍵選單與資料夾空白處右鍵選單加入 **Open in Netcatty**,點了就會在該資料夾開啟本機終端。Windows 11 要先選 **顯示其他選項**。這個選單可以在 **設定 → 系統 → Windows 檔案總管** 隨時關閉或重新開啟。ZIP 版與免安裝版預設不會加入這個選單。
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
### Nix / NixOS
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
```bash
nix run github:binaricat/Netcatty
```
若要以宣告式方式安裝,把 Netcatty flake 加為 input,並在你的 NixOS 或 Home Manager 套件清單中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
### 事前需求
- Node.js 18+ 與 npm
- macOS、Windows 10+ 或 Linux
### 開發
```bash
# 複製儲存庫
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 安裝相依套件
npm install
# 啟動開發模式(Vite + Electron)
npm run dev
```
---
<a name="建置與打包"></a>
# 建置與打包
```bash
# 建置正式版
npm run build
# 為目前平台打包
npm run pack
# 為特定平台打包
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS 安裝程式)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
<a name="技術堆疊"></a>
# 技術堆疊
| 分類 | 技術 |
|------|------|
| 框架 | Electron 40 |
| 前端 | React 19, TypeScript |
| 建置工具 | Vite 7 |
| 終端機 | xterm.js 5 |
| 樣式 | Tailwind CSS 4 |
| SSH/SFTP | ssh2, ssh2-sftp-client |
| PTY | node-pty |
| 圖示 | Lucide React |
---
<a name="參與貢獻"></a>
# 參與貢獻
歡迎參與貢獻!請隨時送出 Pull Request。
1. Fork 本儲存庫
2. 建立你的功能分支(`git checkout -b feature/amazing-feature`)
3. 提交你的變更(`git commit -m 'Add some amazing feature'`)
4. 推送到該分支(`git push origin feature/amazing-feature`)
5. 開一個 Pull Request
架構概觀與程式碼慣例請見 [AGENTS.md](AGENTS.md)。
---
<a name="貢獻者"></a>
# 貢獻者
感謝每一位參與貢獻的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="授權條款"></a>
# 授權條款
本專案採用 **GPL-3.0 授權** —— 詳情請見 [LICENSE](LICENSE) 檔案。
---
<a name="star-紀錄"></a>
# Star 紀錄
<a href="https://www.star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="docs/assets/star-history/star-history-light.svg" />
</picture>
</a>
---
<p align="center">
由 <a href="https://ko-fi.com/binaricat">binaricat</a> 用 ❤️ 打造
</p>
+232 -1
View File
@@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import {
executeHotkeyActionImpl,
getLogHostVisualSnapshot,
handleEscapeKeyDownImpl,
handleGlobalHotkeyKeyDownImpl,
markForwardedNativeShortcutEvent,
} from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -30,6 +36,14 @@ class FakeHTMLElement {
}
}
class FakeMonacoHTMLElement extends FakeHTMLElement {
tagName = 'TEXTAREA';
closest(selector: string): FakeMonacoHTMLElement | null {
return selector.includes('monaco') ? this : null;
}
}
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
@@ -111,6 +125,81 @@ test('global hotkey handler routes quick switch through focused search inputs',
assert.deepEqual(handledActions, ['quickSwitch']);
});
test('global hotkey handler magnifies panes from focused form inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const event = {
key: 'm',
code: 'KeyM',
ctrlKey: false,
metaKey: false,
altKey: true,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['togglePaneZoom']);
});
test('forwarded native shortcut can run a reassigned global action from Monaco', () => {
const target = new FakeMonacoHTMLElement();
const handledActions: string[] = [];
let prevented = false;
const event = markForwardedNativeShortcutEvent({
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {},
} as unknown as KeyboardEvent);
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
if (binding.action === 'closeTab') return { ...binding, mac: 'Disabled' };
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
return binding;
});
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Disabled',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'mac',
keyBindings,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['newTab']);
assert.equal(prevented, true);
});
test('quick switch hotkey toggles the quick switcher open state', () => {
let isQuickSwitcherOpen = false;
const setIsQuickSwitcherOpen = (next: boolean) => {
@@ -170,6 +259,148 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
assert.equal(isQuickSwitcherOpen, false);
});
test('pane zoom hotkey delegates to the active in-app magnification surface', () => {
let toggles = 0;
const noop = () => {};
const controller = {
getState: () => 'focusable' as const,
focus: () => false,
restore: () => false,
toggle: () => {
toggles += 1;
return true;
},
};
executeHotkeyActionImpl(() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'workspace-1' },
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 },
sftpPaneMagnificationRef: { current: null },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
terminalPaneMagnificationRef: { current: controller },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWorkspaceViewMode: noop,
workspaces: [],
}), 'togglePaneZoom', {} as KeyboardEvent);
assert.equal(toggles, 1);
});
test('move-focus shortcut cannot send input behind a magnified pane', () => {
let moveCalls = 0;
executeHotkeyActionImpl(() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'workspace-1' },
editorTabs: [],
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: () => {
moveCalls += 1;
return true;
},
orderedTabs: [],
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
sftpPaneMagnificationRef: { current: null },
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused' as const,
focus: () => false,
restore: () => true,
toggle: () => true,
},
},
toEditorTabId: (id: string) => `editor:${id}`,
workspaces: [{ id: 'workspace-1', title: 'Workspace' }],
}), 'moveFocus', {
key: 'ArrowRight',
} as KeyboardEvent);
assert.equal(moveCalls, 0);
});
test('Escape restores magnification after transient dialogs are closed', () => {
let restores = 0;
let prevented = false;
let stopped = false;
const event = {
key: 'Escape',
defaultPrevented: false,
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: false,
setIsQuickSwitcherOpen: () => {},
sftpPaneMagnificationRef: { current: null },
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused',
focus: () => false,
restore: () => {
restores += 1;
return true;
},
toggle: () => false,
},
},
}), event);
assert.equal(restores, 1);
assert.equal(prevented, true);
assert.equal(stopped, true);
});
test('consumed Escape does not restore magnification', () => {
let restores = 0;
handleEscapeKeyDownImpl(() => ({
isQuickSwitcherOpen: false,
setIsQuickSwitcherOpen: () => {},
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused',
focus: () => false,
restore: () => {
restores += 1;
return true;
},
toggle: () => false,
},
},
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
assert.equal(restores, 0);
});
test('close tab hotkey routes native plugin view tabs through their owner', () => {
let closedTabId = '';
const pluginTabId = 'plugin-view:com.example.view:com.example.view.panel';
+44 -10
View File
@@ -4,6 +4,7 @@ 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 { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
@@ -13,6 +14,12 @@ import { captureInheritedCwd } from '../state/inheritedCwd';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const forwardedNativeShortcutEvents = new WeakSet<KeyboardEvent>();
export function markForwardedNativeShortcutEvent(event: KeyboardEvent): KeyboardEvent {
forwardedNativeShortcutEvents.add(event);
return event;
}
async function deliverKeyboardInteractiveResponse(
ctx: Record<string, any>,
@@ -259,8 +266,18 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
if (
(isFormElement || isMonacoElement)
&& !isXtermInput
&& e.key !== 'Escape'
&& !isQuickSwitchHotkey
&& !isPaneZoomHotkey
&& !forwardedNativeShortcutEvents.has(e)
) {
return;
}
@@ -315,10 +332,24 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { isQuickSwitcherOpen, setIsQuickSwitcherOpen } = getCtx();
const {
isQuickSwitcherOpen,
setIsQuickSwitcherOpen,
sftpPaneMagnificationRef,
terminalPaneMagnificationRef,
} = getCtx();
{
if (e.key === 'Escape' && isQuickSwitcherOpen) {
if (e.key !== 'Escape' || e.defaultPrevented) return;
if (isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
return;
}
if (getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.restore()) {
e.preventDefault();
e.stopPropagation();
}
}
}
@@ -671,7 +702,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, 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, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, 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, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -907,15 +938,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
break;
}
case 'togglePaneZoom': {
// Toggle workspace between split and focus (zoom) mode
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleWorkspaceViewMode(activeWs.id);
}
getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.toggle();
break;
}
case 'moveFocus': {
const magnificationController = getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
]);
if (magnificationController?.getState() === 'focused') break;
// Debounce to prevent double-triggering when focus switches between terminals
const now = Date.now();
if (now - lastMoveFocusTimeRef.current < MOVE_FOCUS_DEBOUNCE_MS) {
@@ -10,6 +10,7 @@ const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
@@ -79,10 +80,12 @@ test('App.tsx does not subscribe to vault/session/settings runtimes or mega hook
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
@@ -0,0 +1,18 @@
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("open-terminal requests wait behind App Lock and resume after unlock", () => {
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
const drainSource = source.slice(drainStart, drainEnd);
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
});
@@ -0,0 +1,15 @@
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('terminal Escape restoration runs before xterm can stop propagation', () => {
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
const effectSource = source.slice(effectStart, effectStart + 900);
assert.notEqual(effectStart, -1);
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
});
+145 -25
View File
@@ -7,10 +7,12 @@ import { useManagedSourceSync } from '../state/useManagedSourceSync';
import { usePortForwardingState } from '../state/usePortForwardingState';
import { useUpdateCheck } from '../state/useUpdateCheck';
import {
useAppLockChrome,
useAppSessionRuntime,
useAppSettingsRuntime,
useAppVaultRuntime,
} from '../state/appRuntimeBridge';
import { shouldDeferExternalActionWhileAppLocked } from '../../components/AppLockGate';
import {
getConnectionLogsSnapshot,
subscribeConnectionLogs,
@@ -50,9 +52,10 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import type { SyncPayload } from '../../domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import {
@@ -87,7 +90,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -101,6 +104,7 @@ const HOTKEY_DEBUG =
export function AppSideEffects() {
const settings = useAppSettingsRuntime();
const { locked: appLockLocked } = useAppLockChrome();
const { t } = useI18n();
const pluginViewTabs = usePluginViewTabs();
@@ -593,7 +597,7 @@ export function AppSideEffects() {
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
// dedicated transfer resume so quick-connect rows do not break tray connect.
@@ -620,6 +624,20 @@ export function AppSideEffects() {
workspaces,
});
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
// Saved-credential tunnels must not start/stop behind the lock overlay.
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
const pending = pendingTrayPortForwardsWhileLockedRef.current;
const existing = pending.findIndex((item) => item.ruleId === ruleId);
if (existing >= 0) pending.splice(existing, 1);
pending.push({ ruleId, start });
return;
}
_handleTrayTogglePortForward(ruleId, start);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -629,7 +647,7 @@ export function AppSideEffects() {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
_handleTrayTogglePortForward(ruleId, start);
_handleTrayTogglePortForwardMaybeDeferred(ruleId, start);
});
return () => {
@@ -638,6 +656,14 @@ export function AppSideEffects() {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingTrayPortForwardsWhileLockedRef.current.splice(0);
for (const item of pending) {
_handleTrayTogglePortForward(item.ruleId, item.start);
}
}, [appLockLocked]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -898,6 +924,8 @@ export function AppSideEffects() {
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const toggleSidePanelRef = useRef<(() => void) | null>(null);
const terminalPaneMagnificationRef = useRef<PaneMagnificationController | null>(null);
const sftpPaneMagnificationRef = useRef<PaneMagnificationController | null>(null);
const openNoteRequestIdRef = useRef(0);
const [openNoteRequest, setOpenNoteRequest] = useState<{
tabId: string;
@@ -1040,8 +1068,10 @@ export function AppSideEffects() {
showSftpTab: showSftpTabRef.current,
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
},
sftpPaneMagnificationRef,
splitSessionWithCurrentShell,
systemInfoRef,
terminalPaneMagnificationRef,
toEditorTabId,
toggleBroadcast,
toggleScriptsSidePanelRef,
@@ -1066,10 +1096,6 @@ export function AppSideEffects() {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
topmostDialogClose.click();
return;
}
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
@@ -1078,8 +1104,29 @@ export function AppSideEffects() {
workspaceIds: workspaces.map((workspace) => workspace.id),
logViewIds: logViews.map((logView) => logView.id),
pluginViewTabIds: pluginViewTabs.map((tab) => tab.id),
hasOpenDialog: Boolean(topmostDialogClose),
closeTabShortcutEnabled: isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true),
});
if (intent.kind === 'forwardShortcut') {
// The native macOS menu accelerator consumed the original key event.
// Re-dispatch it so a freed Cmd+W can still be assigned to another action.
const forwardedEvent = markForwardedNativeShortcutEvent(new KeyboardEvent('keydown', {
key: 'w',
code: 'KeyW',
metaKey: true,
bubbles: true,
cancelable: true,
}));
(document.activeElement ?? window).dispatchEvent(forwardedEvent);
return;
}
if (intent.kind === 'closeDialog') {
topmostDialogClose?.click();
return;
}
if (intent.kind === 'closeTab') {
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
return;
@@ -1091,23 +1138,27 @@ export function AppSideEffects() {
}
await netcattyBridge.get()?.windowClose?.();
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
}, [closeLogView, closeTabKeyStr, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
useEffect(() => {
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
// Gate it while locked so sessions/tabs cannot close behind the overlay.
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
void handleWindowCommandCloseRequest();
});
return () => unsubscribe?.();
}, [handleWindowCommandCloseRequest]);
}, [appLockLocked, handleWindowCommandCloseRequest]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
executeHotkeyAction(action, e);
}, [executeHotkeyAction]);
// Global hotkey handler
// Global hotkey handler — suppress while the app lock overlay is up so
// capture-phase shortcuts cannot mutate sessions behind the lock screen.
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
if (hotkeyScheme === 'disabled' || isHotkeyRecording || appLockLocked) return;
const handleGlobalKeyDown = (e: KeyboardEvent) => {
_handleGlobalHotkeyKeyDown(e);
@@ -1115,15 +1166,26 @@ export function AppSideEffects() {
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, isHotkeyRecording]);
}, [hotkeyScheme, isHotkeyRecording, appLockLocked]);
useEffect(() => {
if (appLockLocked) return;
const onCaptureKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target;
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
_handleEscapeKeyDown(e);
};
const onKeyDown = (e: KeyboardEvent) => {
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onCaptureKeyDown, true);
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return () => {
window.removeEventListener('keydown', onCaptureKeyDown, true);
window.removeEventListener('keydown', onKeyDown);
};
}, [appLockLocked]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -1390,7 +1452,17 @@ export function AppSideEffects() {
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
// Idle/background/manual locks keep children mounted under the overlay. Queue
// 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: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
@@ -1439,6 +1511,14 @@ export function AppSideEffects() {
setActiveTabId('vault');
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
return;
}
_processSshDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1448,7 +1528,7 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseTelnetDeepLink(rawUrl);
@@ -1486,6 +1566,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
return;
}
_processTelnetDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1495,7 +1583,7 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
@@ -1515,6 +1603,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'jms', payload: payload || {} });
return;
}
_processJmsDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1524,6 +1620,35 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path',
payload: payload || {},
});
return;
}
_processOpenTerminalPath(payload);
});
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingDeepLinksWhileLockedRef.current.splice(0);
for (const item of pending) {
if (item.kind === 'ssh') _processSshDeepLink(item.payload);
else if (item.kind === 'telnet') _processTelnetDeepLink(item.payload);
else if (item.kind === 'jms') _processJmsDeepLink(item.payload);
else _processOpenTerminalPath(item.payload);
}
}, [appLockLocked]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
@@ -1535,13 +1660,6 @@ export function AppSideEffects() {
});
}, [sessions]);
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1767,6 +1885,8 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
terminalPaneMagnificationRef,
sftpPaneMagnificationRef,
// Chrome glue
handleEndSessionDrag,
handleOpenQuickSwitcher,
+36 -1
View File
@@ -29,12 +29,14 @@ import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { getAppAppLockRuntime, useAppLockChrome } from '../state/appRuntimeBridge';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
@@ -275,7 +277,7 @@ function AppViewInner({ domains }: AppViewProps) {
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
@@ -301,6 +303,32 @@ function AppViewInner({ domains }: AppViewProps) {
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
const paneMagnificationController = getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
]);
const paneMagnificationState = paneMagnificationController?.getState() ?? 'unavailable';
const handleMagnifyCurrentPane = () => {
getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.focus();
};
const handleRestoreMagnifiedPane = () => {
getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.restore();
};
// App Lock chrome: narrow slice published by AppLockRuntimePublisher; the
// lock action reads the full runtime imperatively so the callback stays
// stable across gate re-renders.
const { appLockEnabled } = useAppLockChrome();
const handleLockApp = useCallback(() => {
getAppAppLockRuntime()?.lockNow('manual');
}, []);
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -485,6 +513,8 @@ function AppViewInner({ domains }: AppViewProps) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
onLockApp={handleLockApp}
appLockEnabled={appLockEnabled}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
@@ -633,6 +663,7 @@ function AppViewInner({ domains }: AppViewProps) {
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
paneMagnificationRef={sftpPaneMagnificationRef}
/>
<TerminalLayerMount
@@ -727,6 +758,7 @@ function AppViewInner({ domains }: AppViewProps) {
showHostTreeSidebar={showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
paneMagnificationRef={terminalPaneMagnificationRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
@@ -878,6 +910,9 @@ function AppViewInner({ domains }: AppViewProps) {
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
</Suspense>
</LazyLoadBoundary>
+2
View File
@@ -239,6 +239,8 @@ export function TerminalHost() {
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
@@ -0,0 +1,53 @@
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
import {
AppLockChromeContext,
registerAppAppLockRuntime,
type AppAppLockRuntime,
} from '../../state/appRuntimeBridge';
export type AppLockRuntimePublisherProps = {
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
appLock: AppAppLockRuntime;
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
appLockEnabled: boolean;
children?: ReactNode;
};
/**
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
* slice goes on context so TopTabs / AppSideEffects re-render only when the
* lock state actually changes — the full runtime changes identity on every
* gate render.
*/
export function AppLockRuntimePublisher({
appLock,
appLockEnabled,
children,
}: AppLockRuntimePublisherProps) {
useLayoutEffect(() => {
registerAppAppLockRuntime(appLock);
}, [appLock]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppAppLockRuntime(null);
}, []);
const chrome = useMemo(
() => ({
appLockEnabled,
locked: appLock.locked,
initialized: appLock.initialized,
}),
[appLockEnabled, appLock.locked, appLock.initialized],
);
return (
<AppLockChromeContext.Provider value={chrome}>
{children}
</AppLockChromeContext.Provider>
);
}
@@ -3,33 +3,29 @@ import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
type AppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
/**
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
* owns `useSettingsState` so the lock overlay can render before app children
* mount; the publisher only binds the runtime slot and context.
*/
settings: AppSettingsRuntime;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
* `SessionPublisher` hand over their runtimes: a context for render-time reads
* and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
* happens inside `useSettingsState`, so this publisher only relocates the
* binding out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
@@ -20,6 +20,10 @@ const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
const appLockBridgeSource = readFileSync(
new URL('../state/useAppLockBridge.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
@@ -58,10 +62,10 @@ test('clone-session payload is consumed once even if the effect re-runs', () =>
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
@@ -3,6 +3,7 @@ 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';
@@ -39,7 +40,7 @@ const keys = [
] as const;
test('convergent sync copy exists in every bundled locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
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}`);
@@ -3,6 +3,7 @@ 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";
const strategyKeys = [
@@ -17,7 +18,7 @@ const strategyKeys = [
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
for (const key of strategyKeys) {
assert.equal(
typeof messages[key],
@@ -3,6 +3,7 @@ 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';
@@ -19,7 +20,7 @@ const STEER_KEYS = [
] as const;
test('Codex steering UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = STEER_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
}
+24 -3
View File
@@ -43,6 +43,10 @@ export const enAiMessages: Messages = {
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.openaiApi': 'OpenAI API format',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions works with most OpenAI-compatible endpoints. Responses can raise cache hit rates on relays that support /v1/responses.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
@@ -54,6 +58,7 @@ export const enAiMessages: Messages = {
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Local Ollama: http://localhost:11434/v1 (no API key). Ollama Cloud: https://ollama.com/v1 plus your cloud API key.',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
@@ -442,15 +447,28 @@ export const enAiMessages: Messages = {
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Ask before Netcatty writes (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Execute freely (Cursor CLI: agent + force)',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before writes',
'ai.chat.permAutoDesc': 'Run freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
'ai.chat.selectProvider': 'Select provider',
'ai.chat.selectProviderAndModel': 'Select provider and model',
'ai.chat.selectModel': 'Select model',
'ai.chat.searchModels': 'Search models',
'ai.chat.providers': 'Providers',
'ai.chat.models': 'Models',
'ai.chat.pinned': 'Pinned',
'ai.chat.useCustomModel': 'Use "{id}"',
'ai.chat.thinkingLevel': 'Thinking',
'ai.chat.thinkingOff': 'Off',
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
@@ -516,6 +534,9 @@ export const enAiMessages: Messages = {
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.responseIdleTimeout': 'Built-in AI Response Wait',
'ai.safety.responseIdleTimeout.description': 'Cancel a built-in AI request after this many seconds without a new response. This setting does not control total response time or command execution.',
'ai.safety.responseIdleTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
+90
View File
@@ -83,6 +83,32 @@ export const enCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// App Lock
'appLock.title': 'Netcatty is locked',
'appLock.reason.default': 'Enter your lock password to continue.',
'appLock.reason.startup': 'Netcatty locks each time this window opens.',
'appLock.reason.idle': 'Netcatty locked after the inactivity timeout.',
'appLock.reason.manual': 'Netcatty was locked manually.',
'appLock.passwordLabel': 'Lock password',
'appLock.passwordPlaceholder': 'Enter password to unlock',
'appLock.unlock': 'Unlock',
'appLock.unlocking': 'Unlocking...',
'appLock.usePassword': 'Use password instead',
'appLock.error.emptyPassword': 'Enter the lock password.',
'appLock.error.incorrectPassword': 'Incorrect lock password.',
'appLock.systemUnlock.unlockWith': 'Unlock with {label}',
'appLock.systemUnlock.preparing': 'Preparing {label}...',
'appLock.systemUnlock.verifying': 'Waiting for {label}...',
'appLock.systemUnlock.error': 'System unlock was not completed. Use the lock password to continue.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Reset App Lock',
'appLock.reset.description': 'This removes the current lock password and turns off App Lock. Set a new password from Settings when you need it again.',
'appLock.reset.cancel': 'Cancel',
'appLock.reset.confirm': 'Reset App Lock',
'appLock.reset.resetting': 'Resetting...',
'appLock.reset.error': 'Could not reset App Lock. Try again.',
'topTabs.lockApp': 'Lock Netcatty',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
@@ -118,6 +144,59 @@ export const enCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > App Lock
'settings.appLock.title': 'App Lock',
'settings.appLock.description': 'Require a local lock password when Netcatty opens, after inactivity, or when you lock it manually.',
'settings.appLock.setupTitle': 'Set a lock password',
'settings.appLock.setupDescription': 'Create the password below. App Lock turns on immediately after it is saved.',
'settings.appLock.manageTitle': 'App Lock is ready',
'settings.appLock.enabledStatus': 'Enabled. Netcatty locks on startup, after inactivity, and when you lock it manually.',
'settings.appLock.disabledStatus': 'Password saved, but App Lock is currently off.',
'settings.appLock.disableTitle': 'Turn off App Lock',
'settings.appLock.disableDescription': 'Enter the current lock password to stop locking Netcatty. The saved password will be removed; turning App Lock on again requires creating a new one.',
'settings.appLock.disable': 'Turn off App Lock',
'settings.appLock.disabling': 'Turning off...',
'settings.appLock.setupPasswordTitle': 'Create lock password',
'settings.appLock.setupPasswordDescription': 'This password is required to unlock Netcatty and to change or turn off App Lock later.',
'settings.appLock.changePasswordTitle': 'Change lock password',
'settings.appLock.changePasswordDescription': 'Enter the current lock password, then choose a new one.',
'settings.appLock.enable': 'Enable app lock',
'settings.appLock.enableDesc': 'Lock on startup and after the selected inactivity timeout.',
'settings.appLock.timeout': 'Inactivity timeout',
'settings.appLock.timeoutDesc': 'Lock after inactivity, or when the window is closed to the tray. Never skips both.',
'settings.appLock.timeout.0': 'Never lock automatically',
'settings.appLock.timeout.1': '1 minute',
'settings.appLock.timeout.5': '5 minutes',
'settings.appLock.timeout.15': '15 minutes',
'settings.appLock.timeout.30': '30 minutes',
'settings.appLock.timeout.60': '60 minutes',
'settings.appLock.systemUnlock.label': '{label} unlock',
'settings.appLock.systemUnlock.desc': 'Use {label} as an alternate way to unlock Netcatty on this device.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} is not available on this device right now.',
'settings.appLock.systemUnlock.unavailable': 'System unlock is not available on this device.',
'settings.appLock.systemUnlock.locked': 'Unlock Netcatty before changing system unlock.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Open {label} automatically',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Show the {label} prompt automatically when Netcatty is waiting to unlock.',
'settings.appLock.currentPassword': 'Current password',
'settings.appLock.currentPasswordPlaceholder': 'Required to disable or replace',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Current password to turn off App Lock',
'settings.appLock.currentPasswordForChangePlaceholder': 'Current password to change it',
'settings.appLock.newPassword': 'New password',
'settings.appLock.newPasswordPlaceholder': 'Set lock password',
'settings.appLock.confirmPassword': 'Confirm password',
'settings.appLock.confirmPasswordPlaceholder': 'Repeat password',
'settings.appLock.savePassword': 'Set and enable',
'settings.appLock.savingPassword': 'Saving...',
'settings.appLock.passwordSet': 'Password configured',
'settings.appLock.replacePassword': 'Replace password',
'settings.appLock.enableAfterPassword': 'Set a lock password before enabling app lock.',
'settings.appLock.localOnlyHint': 'Device-local privacy lock. It is not synced and is not an OS security boundary.',
'settings.appLock.validation.currentRequired': 'Enter the current lock password.',
'settings.appLock.validation.newRequired': 'Enter a new lock password.',
'settings.appLock.validation.confirmRequired': 'Confirm the new lock password.',
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
@@ -564,6 +643,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.autoCloseOnExit': 'Auto-close terminal on exit',
'settings.terminal.behavior.autoCloseOnExit.desc':
'Allow terminal tabs and windows to close automatically after session exit. Turn this off to keep them open after every exit.',
'settings.terminal.behavior.disconnectedNotice': 'When a connection disconnects',
'settings.terminal.behavior.disconnectedNotice.desc':
'Show a compact notice inside established terminals, or use the full disconnected dialog. Initial connection failures still use the dialog.',
'settings.terminal.behavior.disconnectedNotice.terminal': 'Terminal notice',
'settings.terminal.behavior.disconnectedNotice.dialog': 'Dialog',
'settings.terminal.behavior.rightClick.menu': 'Show menu',
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
@@ -610,6 +694,12 @@ 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.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.',
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
'settings.terminal.behavior.oscNotifications.always': 'Always',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
+5 -1
View File
@@ -62,6 +62,9 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.paneMagnification.magnify': 'Magnify Current Pane',
'terminal.paneMagnification.restore': 'Restore Pane Layout',
'terminal.paneMagnification.hint': 'Magnified',
'terminal.toolbar.detach': 'Detach to standalone tab',
'terminal.toolbar.dragPane': 'Drag terminal pane',
'terminal.toolbar.showActions': 'Show terminal actions',
@@ -183,6 +186,7 @@ export const enTerminalMessages: Messages = {
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
'terminal.progress.reconnecting': 'Reconnecting...',
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
@@ -748,7 +752,7 @@ export const enTerminalMessages: Messages = {
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with {name}',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
'snippets.variables.dialogTitle': 'Snippet variables',
+13 -1
View File
@@ -208,6 +208,7 @@ export const enVaultMessages: Messages = {
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.owner': 'Owner',
'sftp.columns.configure': 'Choose visible columns',
'sftp.columns.actions': 'Actions',
'sftp.sort.directoriesFirst': 'Folders first',
@@ -229,6 +230,10 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.extract': 'Extract here',
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
@@ -391,6 +396,13 @@ export const enVaultMessages: Messages = {
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.folderTitle': 'Folder Conflict',
'sftp.conflict.folderDesc': 'A folder with the same name already exists at the destination',
'sftp.conflict.folderFileDesc': 'A file with the same name already exists at the destination. A folder cannot be merged with a file.',
'sftp.conflict.folderSymlinkDesc': 'A symbolic link with the same name already exists at the destination. A folder cannot be merged with a symbolic link.',
'sftp.conflict.folderUnknownDesc': 'An item with the same name exists at the destination, but its type could not be confirmed. Replace is unavailable to protect destination data.',
'sftp.conflict.folderMergeHint': 'Merge keeps files and sub-folders that exist only in the destination.',
'sftp.conflict.folderReplaceWarning': 'Replace deletes all destination files and sub-folders that are not in the source. Deleted data cannot be recovered.',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
'sftp.conflict.existingFile': 'Existing file',
'sftp.conflict.newFile': 'New file',
@@ -489,7 +501,7 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
+20
View File
@@ -0,0 +1,20 @@
import type { Messages } from './types';
import { esCoreMessages } from './es/core';
import { esVaultMessages } from './es/vault';
import { esTerminalMessages } from './es/terminal';
import { esAiMessages } from './es/ai';
import { esSystemManagerMessages } from './es/systemManager';
import { esScriptsMessages } from './es/scripts';
export type { Messages } from './types';
const es: Messages = {
...esCoreMessages,
...esVaultMessages,
...esTerminalMessages,
...esAiMessages,
...esSystemManagerMessages,
...esScriptsMessages,
};
export default es;
+659
View File
@@ -0,0 +1,659 @@
import type { Messages } from '../types';
export const esAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
'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.contextUsage': 'Uso de contexto: {used} / {max} tokens',
'ai.chat.activity.title': 'Actividad del agente',
'ai.chat.activity.plan': 'Plan',
'ai.chat.activity.webSearch': 'Búsqueda web',
'ai.chat.activity.fileChanges': 'Cambios de archivos',
'ai.chat.activity.status.running': 'En ejecución',
'ai.chat.activity.status.completed': 'Completado',
'ai.chat.activity.status.failed': 'Falló',
'ai.chat.activity.file.add': 'Agregar',
'ai.chat.activity.file.update': 'Actualizar',
'ai.chat.activity.file.delete': 'Eliminar',
'ai.chat.activity.usage': 'Tokens',
'ai.chat.activity.usage.input': 'entrada',
'ai.chat.activity.usage.output': 'salida',
'ai.chat.activity.usage.cached': 'en caché',
'ai.chat.activity.usage.reasoning': 'razonamiento',
'ai.title': 'IA',
'ai.description': 'Configura proveedores de IA, agentes y ajustes de seguridad',
'ai.providers': 'Proveedores',
'ai.agents': 'Agentes',
'ai.providers.empty': 'No hay proveedores configurados. Agrega un proveedor para comenzar.',
'ai.providers.add': 'Agregar proveedor',
'ai.providers.active': 'Activo',
'ai.providers.apiKeyConfigured': 'Clave de API configurada',
'ai.providers.noApiKey': 'Sin clave de API',
'ai.providers.configure': 'Configurar',
'ai.providers.remove': 'Quitar',
'ai.providers.name': 'Nombre para mostrar',
'ai.providers.name.placeholder': 'p. ej. Mi proveedor',
'ai.providers.style': 'Estilo de protocolo',
'ai.providers.style.anthropic': 'Compatible con Anthropic',
'ai.providers.style.openai': 'Compatible con OpenAI',
'ai.providers.style.google': 'Compatible con Google',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
'ai.providers.openaiApi': 'Formato de API de OpenAI',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions funciona con la mayoría de endpoints compatibles con OpenAI. Responses puede mejorar la tasa de aciertos de caché en relés que admiten /v1/responses.',
'ai.providers.icon.change': 'Cambiar ícono',
'ai.providers.icon.upload': 'Subir imagen',
'ai.providers.icon.reset': 'Restablecer',
'ai.providers.icon.close': 'Cerrar',
'ai.providers.icon.uploadedNote': 'Ícono personalizado (64×64 WebP)',
'ai.providers.icon.errorType': 'Elige un archivo de imagen.',
'ai.providers.apiKey': 'Clave de API',
'ai.providers.apiKey.placeholder': 'Ingresa la clave de API',
'ai.providers.apiKey.decrypting': 'Descifrando...',
'ai.providers.baseUrl': 'URL base',
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Ollama local: http://localhost:11434/v1 (sin API key). Ollama Cloud: https://ollama.com/v1 y tu clave de Cloud.',
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
'ai.providers.defaultModel': 'Modelo predeterminado',
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Ventana de contexto',
'ai.providers.contextWindow.placeholder': 'p. ej. 128000',
'ai.providers.contextWindow.help': 'Déjalo en blanco para usar el valor de la lista de modelos cuando esté disponible; de lo contrario, Netcatty usa un valor predeterminado seguro.',
'ai.providers.contextWindow.error': 'Ingresa un número entero positivo o déjalo en blanco.',
'ai.providers.refreshModels': 'Actualizar modelos',
'ai.providers.test': 'Probar',
'ai.providers.test.testing': 'Probando…',
'ai.providers.test.ok': 'Conectado ({latency} ms)',
'ai.providers.test.warn': 'Se alcanzó el endpoint, pero la respuesta parece incompleta ({latency} ms)',
'ai.providers.test.warnSlow': 'Conectado, pero lento ({latency} ms)',
'ai.providers.test.error': 'Falló ({detail})',
'ai.providers.test.missingBaseUrl': 'Primero ingresa una URL base',
'ai.providers.test.missingApiKey': 'Primero ingresa una clave de API',
'ai.providers.test.unavailable': 'La prueba de conexión no está disponible en este entorno',
'ai.providers.searchModel': 'Busca o escribe el ID del modelo...',
'ai.providers.filterModels': 'Filtrar modelos...',
'ai.providers.loadingModels': 'Cargando modelos...',
'ai.providers.noMatchingModels': 'No hay modelos que coincidan',
'ai.providers.clickToLoadModels': 'Haz clic para cargar los modelos',
'ai.providers.showingModels': 'Mostrando los primeros 100 de {count} modelos. Escribe para filtrar.',
'ai.providers.advancedParams': 'Parámetros avanzados',
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Conecta OpenAI Codex. Inicia sesión con ChatGPT aquí, o habilita una clave de API de un proveedor compatible con OpenAI y un endpoint personalizado en Configuración.',
'ai.codex.appServer.title': 'Usar Codex App Server',
'ai.codex.appServer.experimental': 'Experimental',
'ai.codex.appServer.description': 'Usa el protocolo persistente de Codex para aprobaciones nativas, controles de sandbox, modelos en vivo y preguntas a mitad de turno. El SDK sigue siendo el predeterminado.',
'ai.codex.appServer.checking': 'Comprobando la compatibilidad con App Server…',
'ai.codex.appServer.available': 'App Server está disponible para esta Codex CLI.',
'ai.codex.appServer.modelCatalogWarning': 'El catálogo de modelos de Codex en vivo no está disponible. Se usa la lista de modelos integrada.',
'ai.codex.appServer.approval.allowSession': 'Permitir por sesión',
'ai.codex.appServer.userInput.title': 'Codex necesita tu aporte',
'ai.codex.appServer.userInput.description': 'Responde estas preguntas para continuar el turno actual.',
'ai.codex.appServer.userInput.other': 'Ingresa otra respuesta',
'ai.codex.appServer.userInput.autoResolve': 'Codex continuará automáticamente si no se proporciona una respuesta a tiempo.',
'ai.codex.appServer.userInput.skip': 'Omitir',
'ai.codex.appServer.userInput.submit': 'Continuar',
'ai.codex.steer.addInstruction': 'Agregar instrucción',
'ai.codex.steer.sending': 'Agregando instrucción…',
'ai.codex.steer.placeholder': 'Agrega una instrucción mientras Codex está trabajando…',
'ai.codex.steer.notSteerableReview': 'Este turno de revisión de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
'ai.codex.steer.notSteerableCompact': 'Este turno de compactación de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
'ai.codex.steer.busy': 'Ya se está enviando otra instrucción a Codex.',
'ai.codex.steer.inactive': 'El turno de Codex ya terminó. Tu borrador se conservó.',
'ai.codex.steer.unsupported': 'Las instrucciones en curso requieren el runtime de Codex App Server.',
'ai.codex.steer.failed': 'Codex no pudo aceptar la instrucción adicional. Tu borrador se conservó.',
'ai.codex.detecting': 'Detectando...',
'ai.codex.notFound': 'No encontrado',
'ai.codex.awaitingLogin': 'Esperando inicio de sesión',
'ai.codex.connectedChatGPT': 'Conectado mediante ChatGPT',
'ai.codex.connectedApiKey': 'Conectado mediante clave de API',
'ai.codex.connectedCustomConfig': 'Conectado mediante ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Configuración personalizada detectada (falta la variable de entorno)',
'ai.codex.customConfigHint': 'Usando el proveedor personalizado "{provider}" configurado en ~/.codex/config.toml: no se necesita inicio de sesión con ChatGPT.',
'ai.codex.customConfigMissingEnvKey': 'Advertencia: {envKey} no está definida en tu entorno de shell. Expórtala (o inicia netcatty desde un shell que la tenga) para que Codex pueda autenticarse.',
'ai.codex.notConnected': 'No conectado',
'ai.codex.statusUnknown': 'Estado desconocido',
'ai.codex.path': 'Ruta:',
'ai.codex.notFoundHint': 'No se pudo encontrar codex en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.codex.customPathPlaceholder': 'p. ej. /usr/local/bin/codex',
'ai.codex.check': 'Verificar',
'ai.codex.resetPath': 'Restablecer',
'ai.codex.openLogin': 'Abrir inicio de sesión',
'ai.codex.logout': 'Cerrar sesión',
'ai.codex.connectChatGPT': 'Conectar ChatGPT',
'ai.codex.refreshStatus': 'Actualizar estado',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "El asistente de codificación agéntica de Anthropic. Requiere la CLI de Claude Code del sistema.",
'ai.claude.detecting': 'Detectando...',
'ai.claude.detected': 'Detectado',
'ai.claude.notFound': 'No encontrado',
'ai.claude.path': 'Ruta:',
'ai.claude.notFoundHint': 'No se pudo encontrar claude en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.claude.customPathPlaceholder': 'p. ej. /usr/local/bin/claude',
'ai.claude.configSection': 'Autenticación y configuración (opcional)',
'ai.claude.configDir': 'Directorio de configuración',
'ai.claude.configDir.placeholder': '~/.claude (déjalo en blanco para usar el predeterminado)',
'ai.claude.configDir.hint': 'Define CLAUDE_CONFIG_DIR: apunta a una carpeta donde hayas ejecutado el inicio de sesión `claude` (contiene settings.json + credenciales).',
'ai.claude.settings': 'Archivo de configuración',
'ai.claude.settings.placeholder': '~/team-settings.json (ruta o JSON en línea {"model":"..."})',
'ai.claude.settings.hint': 'Opcional. Una ruta a settings.json o un JSON en línea, pasado al SDK como `settings`. Se suma a — y es independiente de — el directorio de configuración anterior (se combina por encima, no lo reemplaza).',
'ai.claude.envVars': 'Variables de entorno',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de Claude. Se almacena localmente en texto plano; para claves de API o credenciales, prefiere el directorio de configuración anterior (un inicio de sesión `claude`).',
'ai.claude.check': 'Verificar',
'ai.claude.resetPath': 'Restablecer',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Usa la CLI de GitHub Copilot. Una vez detectada, se puede seleccionar como agente de codificación externo.',
'ai.copilot.detecting': 'Detectando...',
'ai.copilot.detected': 'Detectado',
'ai.copilot.notFound': 'No encontrado',
'ai.copilot.path': 'Ruta:',
'ai.copilot.notFoundHint': 'No se pudo encontrar copilot en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.copilot.customPathPlaceholder': 'p. ej. /usr/local/bin/copilot',
'ai.copilot.check': 'Verificar',
'ai.copilot.resetPath': 'Restablecer',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Usa el SDK de Cursor o el inicio de sesión local de la CLI de Agent.',
'ai.cursor.detecting': 'Detectando...',
'ai.cursor.detected': 'Disponible',
'ai.cursor.notFound': 'No disponible',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Ingresa una clave de API para habilitar Cursor o cambia al inicio de sesión por CLI.',
'ai.cursor.notInstalledHint': 'No se detectó el SDK de Cursor ni la CLI de Agent.',
'ai.cursor.installStatus': 'Runtime de Cursor',
'ai.cursor.installed': 'Detectado',
'ai.cursor.notInstalled': 'No detectado',
'ai.cursor.modeCli': 'Inicio de sesión por CLI',
'ai.cursor.modeApiKey': 'Clave de API',
'ai.cursor.modeCliHint': 'Usa tu sesión local de `cursor-agent login` y la cuota Automática de la suscripción. La clave de API guardada se conserva, pero no se usa en este modo.',
'ai.cursor.modeApiKeyHint': 'Usa la API de Cursor medida. El inicio de sesión por CLI se ignora mientras este modo esté activo.',
'ai.cursor.cliLoginStatus': 'Inicio de sesión por CLI',
'ai.cursor.cliLoginOk': 'Sesión iniciada',
'ai.cursor.cliLoginAs': 'Sesión iniciada como {{email}}',
'ai.cursor.cliLoginMissing': 'Sin sesión iniciada',
'ai.cursor.cliLoginHint': 'Ejecuta `cursor-agent login` en una terminal y luego haz clic en Verificar.',
'ai.cursor.apiKeyStatus': 'Clave de API',
'ai.cursor.apiKeyConfigured': 'Configurada',
'ai.cursor.apiKeyMissing': 'Faltante',
'ai.cursor.apiKeyFromEnv': 'Desde el entorno',
'ai.cursor.apiKey': 'Clave de API',
'ai.cursor.apiKeyPlaceholder': 'Ingresa la clave de API de Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Usando CURSOR_API_KEY; ingresa una clave para anularla',
'ai.cursor.apiKeyEnvHint': 'Cursor puede usar CURSOR_API_KEY de tu shell. Guarda una clave aquí solo si quieres que Netcatty la anule.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty usará la clave guardada aquí antes que CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Guardar',
'ai.cursor.saved': 'Guardada',
'ai.cursor.showApiKey': 'Mostrar clave de API',
'ai.cursor.hideApiKey': 'Ocultar clave de API',
'ai.cursor.customPathPlaceholder': 'p. ej. /usr/local/bin/cursor',
'ai.cursor.check': 'Verificar',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Usa CodeBuddy Code mediante el SDK oficial de Agent (`@tencent-ai/agent-sdk`). Una vez detectado, se puede seleccionar como agente de codificación externo.',
'ai.codebuddy.detecting': 'Detectando...',
'ai.codebuddy.detected': 'Detectado',
'ai.codebuddy.notFound': 'No encontrado',
'ai.codebuddy.path': 'Ruta:',
'ai.codebuddy.notFoundHint': 'No se pudo encontrar codebuddy en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.codebuddy.customPathPlaceholder': 'p. ej. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Verificar',
'ai.codebuddy.resetPath': 'Restablecer',
'ai.codebuddy.configSection': 'Autenticación y configuración (opcional)',
'ai.codebuddy.internetEnv': 'Entorno de Internet',
'ai.codebuddy.internetEnv.default': 'Predeterminado (internacional)',
'ai.codebuddy.internetEnv.internal': 'Interno',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Define CODEBUDDY_INTERNET_ENVIRONMENT: elige Interno o IOA para entornos de red restringidos.',
'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.effort': 'Esfuerzo de razonamiento',
'ai.codebuddy.effort.default': 'Predeterminado',
'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.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.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',
'ai.codebuddy.elicitation.description': 'Revisa la petición para continuar el turno actual.',
'ai.codebuddy.elicitation.select': 'Selecciona una opción',
'ai.codebuddy.elicitation.yes': 'Sí',
'ai.codebuddy.elicitation.no': 'No',
'ai.codebuddy.elicitation.decline': 'Rechazar',
'ai.codebuddy.elicitation.accept': 'Continuar',
'ai.codebuddy.elicitation.validation.required': '{field} es obligatorio.',
'ai.codebuddy.elicitation.validation.invalidType': '{field} tiene un valor no válido.',
'ai.codebuddy.elicitation.validation.integer': '{field} debe ser un número entero.',
'ai.codebuddy.elicitation.validation.notInteger': '{field} debe ser un número entero.',
'ai.codebuddy.elicitation.validation.minimum': '{field} debe ser al menos {limit}.',
'ai.codebuddy.elicitation.validation.maximum': '{field} debe ser como máximo {limit}.',
'ai.codebuddy.elicitation.validation.minLength': '{field} debe contener al menos {limit} caracteres.',
'ai.codebuddy.elicitation.validation.maxLength': '{field} debe contener como máximo {limit} caracteres.',
'ai.codebuddy.elicitation.validation.minItems': 'Selecciona al menos {limit} opciones para {field}.',
'ai.codebuddy.elicitation.validation.maxItems': 'Selecciona como máximo {limit} opciones para {field}.',
'ai.codebuddy.elicitation.validation.format': '{field} debe coincidir con el formato {format}.',
'ai.codebuddy.elicitation.validation.option': 'Selecciona una opción válida para {field}.',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': 'Usa OpenCode mediante el SDK oficial. Configura proveedores y claves en OpenCode y luego selecciónalo como agente de codificación externo.',
'ai.opencode.detecting': 'Detectando...',
'ai.opencode.detected': 'Detectado',
'ai.opencode.notFound': 'No encontrado',
'ai.opencode.path': 'Ruta:',
'ai.opencode.notFoundHint': 'No se pudo encontrar opencode en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.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.",
'ai.grok.detecting': 'Detectando...',
'ai.grok.detected': 'Detectado',
'ai.grok.notFound': 'No encontrado',
'ai.grok.path': 'Ruta:',
'ai.grok.notFoundHint': 'No se pudo encontrar grok en el PATH. Instala la CLI de Grok Build o especifica la ruta del ejecutable a continuación.',
'ai.grok.customPathPlaceholder': 'p. ej. /usr/local/bin/grok',
'ai.grok.check': 'Verificar',
'ai.grok.resetPath': 'Restablecer',
'ai.grok.runtime.acp.title': 'Usar Grok ACP (agente stdio)',
'ai.grok.runtime.acp.default': 'Predeterminado',
'ai.grok.runtime.acp.description':
'Habla con Grok mediante el Protocolo de Cliente de Agente (grok agent stdio). Inyecta el MCP de Netcatty en session/new. Apágalo para usar la ruta original de CLI headless con streaming-json.',
'ai.grok.runtime.streamingJson.hint':
'Usando streaming-json headless (grok -p --output-format streaming-json). El archivo .grok/config.toml del proyecto se usa para la inyección de MCP.',
// AI Default Agent
'ai.defaultAgent': 'Agente predeterminado',
'ai.defaultAgent.description': 'Agente que se usará al iniciar una nueva sesión de IA',
'ai.defaultAgent.catty': 'Catty (Integrado)',
'ai.toolAccess.title': 'Acceso a herramientas',
'ai.toolAccess.mode': 'Modo de acceso de Netcatty',
'ai.toolAccess.description': 'Elige cómo acceden los agentes externos a las sesiones de Netcatty. MCP expone el servidor integrado, mientras que Habilidades + CLI apunta a los agentes hacia las habilidades locales de Netcatty y los comandos de CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Habilidades + CLI',
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'MCP externo',
'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',
'ai.externalMcp.mode.temporary': 'Temporal',
'ai.externalMcp.mode.persistent': 'Siempre activo',
'ai.externalMcp.mode.description': 'El modo temporal se desactiva automáticamente tras el tiempo de inactividad. El modo Siempre activo restaura el MCP externo cuando se inicia Netcatty.',
'ai.externalMcp.idleTimeout': 'Tiempo de inactividad',
'ai.externalMcp.idleTimeout.description': 'En modo temporal, desactiva el MCP externo después de esta cantidad de minutos sin operaciones de MCP.',
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
'ai.externalMcp.sessionIdleTimeout.description': 'Cierra automáticamente las sesiones abiertas por una IA después de esta cantidad de minutos sin actividad de terminal o archivos.',
'ai.externalMcp.usage.title': 'Cómo usarlo',
'ai.externalMcp.usage.keepRunning': '1. Activa el MCP externo y mantén Netcatty en ejecución.',
'ai.externalMcp.usage.localhost': '2. Los clientes se conectan mediante el launcher local (solo 127.0.0.1). El descubrimiento se elimina cuando desactivas el interruptor.',
'ai.externalMcp.usage.permissions': '3. Las operaciones de escritura siguen Configuración → IA → Seguridad (observador / confirmar / automático) y la lista de bloqueo de comandos.',
'ai.externalMcp.usage.capabilities': '4. Todas las herramientas del catálogo están disponibles: terminal, SFTP, Vault y reenvío de puertos. Los secretos (contraseñas / claves privadas) nunca se devuelven.',
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
'ai.externalMcp.permissionMode.unknown': 'Desconocido',
'ai.externalMcp.discovery': 'Descubrimiento',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'No disponible',
'ai.externalMcp.bridgeUnavailable': 'Puente de MCP externo no disponible',
'ai.externalMcp.copy': 'Copiar',
'ai.externalMcp.copied': 'Copiado',
'ai.externalMcp.copyFailed': 'No se pudo copiar. Intenta copiarlo manualmente.',
'ai.externalMcp.refresh': 'Actualizar',
'ai.externalMcp.clientConfiguration': 'Configuración del cliente',
'ai.externalMcp.clientConfiguration.description': 'Elige un cliente para instalarlo con un clic, o copia fragmentos de CLI o configuración.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'Comando CLI',
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
'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.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.status.unavailable': 'No disponible',
'ai.externalMcp.status.disabled': 'Desactivado',
'ai.externalMcp.status.running': 'En ejecución',
'ai.externalMcp.status.starting': 'Iniciando',
'ai.externalMcp.status.error': 'Error',
'ai.externalMcp.status.configured': 'Configurado',
'ai.externalMcp.status.notConfigured': 'No configurado',
'ai.externalMcp.status.checking': 'Comprobando',
'ai.externalMcp.status.codexNotFound': 'Codex no encontrado',
'ai.externalMcp.status.claudeNotFound': 'Claude Code no encontrado',
'ai.externalMcp.status.grokNotFound': 'Grok no encontrado',
'ai.externalMcp.status.conflict': 'Conflicto',
'ai.userSkills.title': 'Habilidades de usuario',
'ai.userSkills.description': 'Abre la carpeta de habilidades de Netcatty para agregar tus propios directorios de habilidades. Netcatty escanea estas habilidades automáticamente e inyecta solo índices ligeros, salvo que una habilidad coincida claramente con la petición actual.',
'ai.userSkills.openFolder': 'Abrir carpeta de habilidades',
'ai.userSkills.reload': 'Recargar habilidades',
'ai.userSkills.location': 'Ubicación',
'ai.userSkills.loading': 'Escaneando habilidades de usuario...',
'ai.userSkills.summary': '{ready} listas, {warnings} advertencias',
'ai.userSkills.empty': 'Aún no se encontraron habilidades de usuario. Abre la carpeta para agregar directorios de habilidades con un archivo SKILL.md.',
'ai.userSkills.unavailable': 'Las habilidades de usuario no están disponibles en este entorno.',
'ai.userSkills.status.ready': 'Lista',
'ai.userSkills.status.warning': 'Advertencia',
// AI Quick Messages
'ai.quickMessages.title': 'Mensajes rápidos',
'ai.quickMessages.description': 'Crea prompts reutilizables que puedes insertar desde el chat de IA con / o con el botón de mensajes rápidos. A diferencia de las habilidades de usuario, los mensajes rápidos llenan el compositor con texto.',
'ai.quickMessages.add': 'Agregar mensaje rápido',
'ai.quickMessages.createTitle': 'Nuevo mensaje rápido',
'ai.quickMessages.editTitle': 'Editar mensaje rápido',
'ai.quickMessages.name': 'Nombre',
'ai.quickMessages.name.placeholder': 'p. ej. Verificar espacio en disco',
'ai.quickMessages.slug': 'Comando',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Descripción (opcional)',
'ai.quickMessages.descriptionField.placeholder': 'Breve indicación sobre lo que hace este prompt',
'ai.quickMessages.content': 'Contenido del mensaje',
'ai.quickMessages.content.placeholder': 'Texto completo del prompt para insertar al seleccionarlo...',
'ai.quickMessages.empty': 'Aún no hay mensajes rápidos. Agrega algunos prompts que uses seguido.',
'ai.quickMessages.confirmDelete': '¿Eliminar el mensaje rápido "{name}"?',
'ai.quickMessages.error.nameRequired': 'El nombre es obligatorio.',
'ai.quickMessages.error.invalidSlug': 'El comando solo puede contener letras minúsculas, números y guiones.',
'ai.quickMessages.error.contentRequired': 'El contenido del mensaje es obligatorio.',
'ai.quickMessages.error.slugTaken': 'Este comando ya lo usa otro mensaje rápido.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Este comando entra en conflicto con la habilidad de usuario "/{slug}". Elige otro.',
'ai.quickMessages.error.maxItems': 'Puedes guardar como máximo {max} mensajes rápidos.',
// AI Chat
'ai.chat.noProvider': 'No hay ningún proveedor de IA configurado. Ve a **Configuración → IA → Proveedores** para agregar y habilitar un proveedor.',
'ai.chat.toolDenied': 'La acción fue rechazada por el usuario.',
'ai.chat.toolApproved': 'Aprobado',
'ai.chat.toolApprovalHint': 'Presiona Enter una vez · Esc para rechazar',
'ai.chat.approve': 'Aprobar',
'ai.chat.approveOnce': 'Una vez',
'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino',
'ai.chat.rawCommand': 'Comando',
'ai.chat.copyCommand': 'Copiar',
'ai.chat.commandCopied': 'Copiado',
'ai.chat.approvalSession': 'Sesión',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': 'Cwd',
'ai.chat.approvalReason': 'Motivo',
'ai.chat.approvalInvocation': 'Invocación',
'ai.chat.permissionRequired': 'Permiso requerido',
'ai.chat.permissionDescription': 'El agente de IA quiere ejecutar una llamada a una herramienta que requiere tu aprobación.',
'ai.chat.commandBlocked': 'Este comando está bloqueado por tu política de seguridad y no se puede ejecutar.',
'ai.chat.recommendAllow': 'Permitir',
'ai.chat.recommendConfirm': 'Confirmar',
'ai.chat.recommendDeny': 'Denegar',
'ai.chat.exportConversation': 'Exportar conversación',
'ai.chat.exportAs': 'Exportar como',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Texto plano',
'ai.chat.thinking': 'Pensando',
'ai.chat.thoughtFor': 'Pensó durante {duration}',
'ai.chat.thought': 'Pensamiento',
'ai.chat.agents': 'Agentes',
'ai.chat.detectedOnMachine': 'Detectado en esta máquina',
'ai.chat.rescan': 'Volver a escanear',
'ai.chat.permObserver': 'Observador',
'ai.chat.permConfirm': 'Confirmar',
'ai.chat.permAuto': 'Automático',
'ai.chat.permObserverDesc': 'Solo lectura',
'ai.chat.permConfirmDesc': 'Preguntar antes de escribir',
'ai.chat.permAutoDesc': 'Ejecutar libremente',
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
'ai.chat.noModel': 'Sin modelo',
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
'ai.chat.selectProvider': 'Seleccionar proveedor',
'ai.chat.selectProviderAndModel': 'Seleccionar proveedor y modelo',
'ai.chat.selectModel': 'Seleccionar modelo',
'ai.chat.searchModels': 'Buscar modelos',
'ai.chat.providers': 'Proveedores',
'ai.chat.models': 'Modelos',
'ai.chat.pinned': 'Fijados',
'ai.chat.useCustomModel': 'Usar "{id}"',
'ai.chat.thinkingLevel': 'Razonamiento',
'ai.chat.thinkingOff': 'Off',
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'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.noSessions': 'No hay sesiones anteriores',
'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',
'ai.chat.menuContext': 'Contexto',
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
'ai.chat.slashSystemCommands': 'Comandos',
'ai.chat.slashQuickMessages': 'Mensajes rápidos',
'ai.chat.slashUserSkills': 'Habilidades de usuario',
'ai.chat.quickMessages': 'Comandos de barra',
'ai.chat.slashNoResults': 'No hay comandos que coincidan',
'ai.chat.slashEmptyHint': 'Agrega prompts en Configuración → IA → Mensajes rápidos.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
// AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
// AI Web Search
'ai.webSearch.title': 'Búsqueda web',
'ai.webSearch.enable': 'Habilitar búsqueda web',
'ai.webSearch.enable.description': 'Permite que el agente de IA busque en la web información actual.',
'ai.webSearch.provider': 'Proveedor de búsqueda',
'ai.webSearch.provider.description': 'Elige un proveedor de API de búsqueda web.',
'ai.webSearch.apiKey': 'Clave de API',
'ai.webSearch.apiKey.description': 'Clave de API para el proveedor de búsqueda seleccionado.',
'ai.webSearch.apiKey.placeholder': 'Ingresa la clave de API...',
'ai.webSearch.apiHost': 'Host de API',
'ai.webSearch.apiHost.description': 'Endpoint de API personalizado. Deja el predeterminado salvo que uses un proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL de tu instancia de SearXNG (obligatoria).',
'ai.webSearch.maxResults': 'Máximo de resultados',
'ai.webSearch.maxResults.description': 'Cantidad máxima de resultados de búsqueda a devolver (1-20).',
// AI Safety Settings
'ai.safety.title': 'Seguridad',
'ai.safety.permissionMode': 'Modo de permisos',
'ai.safety.permissionMode.description': 'Controla cómo interactúa la IA con tus sesiones de terminal de Netcatty. El modo Observador bloquea las operaciones de escritura que pasan por Netcatty. Las CLIs de agentes externos pueden seguir teniendo sus propias herramientas locales y flujo de aprobación.',
'ai.safety.permissionMode.observer': 'Observador: solo lectura, sin acciones',
'ai.safety.permissionMode.confirm': 'Confirmar: preguntar antes de las acciones',
'ai.safety.permissionMode.auto': 'Automático: ejecutar libremente',
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
'ai.safety.commandTimeout.unit': 'seg',
'ai.safety.responseIdleTimeout': 'Espera de respuesta de la IA integrada',
'ai.safety.responseIdleTimeout.description': 'Cancela una solicitud de la IA integrada tras este número de segundos sin una nueva respuesta. No controla la duración total ni la ejecución de comandos.',
'ai.safety.responseIdleTimeout.unit': 'seg',
'ai.safety.maxIterations': 'Máximo de iteraciones',
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
'ai.safety.blocklist.description': 'Patrones regex para bloquear comandos peligrosos ejecutados mediante Netcatty.',
'ai.safety.blocklist.placeholder': 'Patrón regex...',
'ai.safety.blocklist.reset': 'Restablecer a los valores predeterminados',
'ai.safety.blocklist.add': 'Agregar patrón',
'ai.safety.grants.title': 'Memoria de permisos',
'ai.safety.grants.heading': 'Reglas de permitir en modo Confirmar',
'ai.safety.grants.description': 'El modo Confirmar pregunta antes de ejecutar una operación. Las reglas guardadas permiten automáticamente las operaciones que coinciden en todas las sesiones/nodos de terminal, y se pueden editar manualmente.',
'ai.safety.grants.empty': 'Aún no hay reglas guardadas. Aprueba una herramienta con "Permitir siempre" o agrega una manualmente.',
'ai.safety.grants.capability': 'Capacidad',
'ai.safety.grants.sessionPattern': 'Patrón de sesión',
'ai.safety.grants.commandPattern': 'Patrón de comando (opcional)',
'ai.safety.grants.note': 'Nota (opcional)',
'ai.safety.grants.add': 'Agregar regla',
'ai.safety.grants.remove': 'Quitar',
'ai.safety.grants.export': 'Exportar JSON',
'ai.safety.grants.import': 'Importar JSON',
'ai.safety.note': 'Estos ajustes de seguridad se aplican a las acciones que pasan por Netcatty. Las CLIs de agentes externos también pueden exponer herramientas locales regidas por el propio agente.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Agregar terminal',
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'Historial',
'terminal.layer.theme': 'Tema',
'terminal.layer.notes': 'Notas',
'terminal.layer.aiChat': 'Chat de IA',
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
'terminal.layer.closePanel': 'Cerrar panel',
'terminal.layer.closePane': 'Cerrar división',
'terminal.layer.resizeSplit': 'Redimensionar división',
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
'terminal.layer.hostTree.newHost': 'Nuevo host',
'terminal.layer.hostTree.newHostInGroup': 'Nuevo host en este grupo',
'terminal.layer.hostTree.editHost': 'Editar host',
'terminal.layer.hostTree.hostSavedNextConnection': 'Host actualizado. Los ajustes de conexión se aplicarán la próxima vez que te conectes.',
'terminal.layer.hostTree.newGroup': 'Nuevo grupo',
'terminal.layer.hostTree.localShell': 'Shell local',
'terminal.layer.hostTree.tagsEmpty': 'No hay etiquetas disponibles',
'terminal.layer.hostTree.clearTags': 'Borrar selección',
'terminal.layer.hostTree.collapse': 'Contraer lista de hosts',
'terminal.layer.hostTree.expand': 'Expandir lista de hosts',
'terminal.layer.hostTree.empty': 'No se encontraron hosts',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'Usuario',
'terminal.layer.hostTree.details.port': 'Puerto',
'terminal.layer.hostTree.details.protocol': 'Protocolo',
'terminal.layer.hostTree.details.group': 'Grupo',
'terminal.layer.hostTree.details.tags': 'Etiquetas',
'terminal.layer.hostTree.details.lastConnected': 'Última conexión',
'topTabs.openQuickSwitcher': 'Abrir selector rápido',
'topTabs.moreTabs': 'Más pestañas',
'topTabs.aiAssistant': 'Asistente de IA',
'topTabs.newLocalTerminal': 'Nueva terminal local',
'topTabs.controlPanel': 'Controles rápidos',
'topTabs.controlPanel.externalMcp': 'MCP externo',
'topTabs.controlPanel.theme': 'Tema',
'topTabs.controlPanel.theme.light': 'Claro',
'topTabs.controlPanel.theme.dark': 'Oscuro',
'topTabs.controlPanel.theme.system': 'Sistema',
'topTabs.externalMcp.enable': 'Habilitar MCP externo',
'topTabs.externalMcp.disable': 'Desactivar MCP externo',
'topTabs.windowOpacity': 'Opacidad de la ventana',
'topTabs.openSettings': 'Abrir Configuración',
'ai.chat.sessionHistory': 'Historial de sesiones',
'ai.chat.resizeInput': 'Arrastra para redimensionar el campo de mensaje',
'ai.chat.attach': 'Adjuntar',
'ai.chat.terminalSelectionAttachment': 'Selección de la terminal',
'ai.chat.terminalSelectionLines': 'líneas: {count}',
'ai.chat.collapse': 'Contraer',
'ai.chat.expand': 'Expandir',
'ai.chat.enableAgent': 'Habilitar {name}',
'ai.chat.artifact.noteFallback': 'Nota de Vault',
'ai.chat.artifact.openNotes': 'Abrir Notas',
'ai.chat.artifact.openHosts': 'Abrir Hosts',
'ai.chat.artifact.notesSummary': '{count} notas en Vault',
'ai.chat.artifact.hostsSummary': '{count} hosts en Vault',
'ai.chat.artifact.hostsAdded': 'Se agregaron {count} hosts',
'ai.chat.artifact.hostsPreview': 'Vista previa de {count} hosts',
'ai.chat.artifact.failed': 'La operación de Vault falló',
'ai.chat.artifact.unavailableTitle': 'No disponible',
'ai.chat.artifact.noteMissing': 'Esta nota ya no está en tu Vault.',
'ai.chat.artifact.hostMissing': 'Este host ya no está en tu Vault.',
'ai.chat.artifact.snippetMissing': 'Este snippet o script ya no está en tu Vault.',
'ai.chat.artifact.openSnippets': 'Abrir Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets en Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts en Vault',
'ai.chat.artifact.snippetFallback': 'Snippet de Vault',
'ai.chat.artifact.scriptFallback': 'Script de automatización',
'ai.chat.artifact.scriptLanguage': 'Script de {language}',
'ai.chat.artifact.snippetDeleted': 'Snippet eliminado',
'ai.chat.artifact.scriptDeleted': 'Script eliminado',
'ai.chat.artifact.snippetRan': 'Snippet ejecutado',
'ai.chat.artifact.scriptStarted': 'Se inició la ejecución del script',
'ai.chat.artifact.scriptRunStatus': 'Ejecución del script: {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} ejecuciones de scripts',
'ai.chat.artifact.scriptRunStopped': 'Ejecución del script detenida',
'ai.chat.artifact.scriptRunPaused': 'Ejecución del script en pausa',
'ai.chat.artifact.scriptRunResumed': 'Ejecución del script reanudada',
'ai.chat.artifact.scriptReference': 'referencia de la API de nct',
'zmodem.waitingForRemote': 'Esperando al servidor remoto...',
'zmodem.uploading': 'Subiendo',
'zmodem.downloading': 'Descargando',
'zmodem.cancelTransfer': 'Cancelar transferencia (Ctrl+C)',
'zmodem.overwrite.title': 'El archivo remoto ya existe',
'zmodem.overwrite.applyToRest': 'Aplicar a los conflictos restantes',
'zmodem.overwrite.overwrite': 'Sobrescribir',
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
};
File diff suppressed because it is too large. Load diff
+133
View File
@@ -0,0 +1,133 @@
export const esScriptsMessages = {
'scripts.meta.name': 'Nombre',
'scripts.meta.language': 'Lenguaje',
'scripts.meta.description': 'Descripción',
'scripts.meta.descriptionPlaceholder': 'Notas opcionales sobre este script',
'scripts.meta.trigger': 'Disparador',
'scripts.meta.triggerPattern': 'Patrón de salida (regex)',
'scripts.meta.code': 'Script',
'scripts.trigger.manual': 'Ejecución manual',
'scripts.trigger.onConnect': 'Ejecutar al conectar',
'scripts.trigger.onOutput': 'Ejecutar al coincidir con la salida',
'scripts.trigger.onOutputHint': 'Se dispara cuando la salida del servidor coincide con el patrón (el eco de las teclas del usuario se ignora). Sin hosts de destino configurados, escucha en la sesión conectada actual; con hosts de destino, solo en esos hosts. Deshabilitado en aplicaciones de pantalla alterna como vim o htop. Se suprime mientras otro script se ejecuta en esta sesión; se vuelve a revisar cuando termina. Para esperas dentro del flujo, usa nct.screen.waitForText o nct.screen.waitForRegex en el código del script.',
'scripts.actions.save': 'Guardar',
'scripts.actions.runNow': 'Ejecutar ahora',
'scripts.actions.openEditor': 'Abrir editor',
'scripts.actions.openEditorHint': 'Editar el script en una ventana más grande',
'scripts.editor.modalTitle': 'Editor de scripts',
'scripts.editor.modalSubtitle': 'Edita los metadatos y el código del script en un espacio de trabajo más amplio.',
'scripts.editor.lineCount': '{count} líneas',
'scripts.editor.resize': 'Redimensionar el editor',
'scripts.targets.hint': 'Los grupos seleccionados se resuelven dinámicamente, por lo que los hosts agregados después se incluyen automáticamente.',
'scripts.targets.connectOrderHint': 'El orden de ejecución de los scripts de conexión se configura por host en Detalles del host → Automatización.',
'scripts.targets.currentHostMismatch': 'Este script no está asignado al host actual.',
'hostDetails.automation.groupScripts': 'Scripts de grupo heredados',
'hostDetails.automation.groupScriptsHint': 'Estos scripts siguen al grupo del host dinámicamente y se ordenan antes de la cola específica del host.',
'scripts.actions.runNowHint': 'Ejecutar en los destinos seleccionados, o en todos los hosts conectables cuando esa opción está habilitada.',
'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.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.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',
'scripts.running.status.paused': 'En pausa',
'scripts.running.status.completed': 'Completado',
'scripts.running.status.failed': 'Falló',
'scripts.running.waitingFor': 'Esperando {pattern}',
'scripts.running.waitingForLabel': 'Esperando',
'scripts.running.waitingForShellPrompt': 'indicador del shell (# o $)',
'scripts.running.lastSent': 'Enviado: {command}',
'scripts.recording.start': 'Comenzar grabación',
'scripts.recording.active': 'Detener grabación',
'scripts.recording.startHint': 'Graba las acciones en la terminal enfocada y genera código de script nct',
'scripts.recording.unavailableHint': 'La grabación no está disponible aquí — usa la barra lateral de scripts a la derecha',
'scripts.recording.activeHint': 'Grabando esta terminal. Escribe comandos como de costumbre; haz clic en Detener o usa el control REC en la barra de herramientas para finalizar y guardar.',
'scripts.recording.started': 'Grabación iniciada — opera en la terminal',
'scripts.recording.noSession': 'Conecta una terminal primero (pestaña única o espacio de trabajo)',
'scripts.recording.alreadyActive': 'Otra terminal ya está grabando',
'scripts.recording.stop': 'Detener grabación',
'scripts.recording.pause': 'Pausar grabación',
'scripts.recording.resume': 'Reanudar grabación',
'scripts.recording.saveTitle': 'Guardar script grabado',
'scripts.recording.namePlaceholder': 'Nombre del script',
'scripts.recording.packagePlaceholder': 'Guardar en carpeta',
'scripts.recording.rootPackage': 'Raíz',
'scripts.recording.save': 'Guardar',
'scripts.recording.saveAndEdit': 'Guardar y editar',
'scripts.recording.helpTitle': 'Cómo grabar un script',
'scripts.recording.helpIntro': 'La grabación convierte lo que haces en la terminal en un script de automatización reutilizable — útil para despliegues, verificaciones de salud y otras tareas repetitivas.',
'scripts.recording.helpStep1': 'Conecta a un host. Una pestaña de terminal independiente o una terminal dentro de un espacio de trabajo funcionan igual — abre la barra lateral de scripts a la derecha.',
'scripts.recording.helpStep2': 'Haz clic en Comenzar grabación. Una insignia REC roja en la barra de herramientas de la terminal indica que la grabación está activa.',
'scripts.recording.helpStep3': 'Escribe comandos en la terminal como lo harías normalmente. Cada pulsación de Enter se captura como un paso.',
'scripts.recording.helpStep4': 'Cuando termines, haz clic en Detener grabación, o usa el control de detención junto a REC en la barra de herramientas de la terminal.',
'scripts.recording.helpStep5': 'Nombra el script y guárdalo. Elige Guardar y editar si quieres ajustar el código generado en el editor de scripts.',
'scripts.recording.helpTipsTitle': 'Consejos',
'scripts.recording.helpTip1': 'Las pausas de más de 1 segundo entre acciones se registran como tiempo de espera para que la reproducción no sea demasiado rápida.',
'scripts.recording.helpTip2': 'Después de cada comando, la grabación espera un indicador del shell (como $ o #) antes del siguiente paso.',
'scripts.recording.helpTip3': 'La entrada de contraseñas se marca como sensible y no se almacena en texto plano en el script.',
'scripts.recording.helpTip4': 'Para vim, menús u otros flujos interactivos, graba primero los comandos principales y luego refina el script manualmente.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'Aceptar',
'scripts.dialog.required': 'Obligatorio',
'scripts.dialog.numberInvalid': 'Ingresa un número válido',
'scripts.dialog.numberMin': 'Debe ser al menos {min}',
'scripts.dialog.numberMax': 'Debe ser como máximo {max}',
'scripts.dialog.numberStep': 'Debe usar incrementos de {step}',
'scripts.dialog.waitForTimeoutTitle': 'La espera agotó el tiempo',
'scripts.dialog.retry': 'Reintentar',
'scripts.dialog.skip': 'Omitir',
'scripts.dialog.abort': 'Abortar',
'scripts.running.stepProgress': 'Paso {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': 'Progreso',
'scripts.running.operationsCount': '{count} operaciones',
'scripts.running.opsPrefix': '',
'scripts.running.opsSuffix': ' operaciones',
'scripts.running.elapsedLabel': 'Transcurrido',
'scripts.running.lastSentLabel': 'Enviado:',
'scripts.running.elapsed': '{elapsed}',
'scripts.running.completedSummary': 'Completado · {count} operaciones · {elapsed}',
'scripts.running.dismissHint': 'Toca cerrar para descartar',
'scripts.running.dismiss': 'Cerrar',
'scripts.running.viewLogs': 'Ver registros',
'scripts.running.logTitle': '{name} · Registro de ejecución',
'scripts.running.logEmpty': 'Aún no hay salida de registro',
'scripts.running.pause': 'Pausar',
'scripts.running.resume': 'Reanudar',
'scripts.running.stop': 'Detener',
'scripts.recording.saved': 'Script guardado',
'scripts.recording.savedNamed': 'Guardado "{name}"',
'scripts.observer.blocked': 'El modo observador bloquea los scripts que escriben en la terminal.',
'vault.section.scripts': 'Scripts',
'vault.nav.scripts': 'Scripts',
'snippets.action.newScript': 'Nuevo script de automatización',
'hostDetails.section.automation': 'Automatización',
'hostDetails.automation.loginScript': 'Script de inicio de sesión',
'hostDetails.automation.loginScriptPlaceholder': 'Selecciona un script',
'hostDetails.automation.none': 'Ninguno',
'hostDetails.automation.outputTriggers': 'Disparadores de salida',
'hostDetails.automation.addTrigger': 'Agregar disparador',
'hostDetails.automation.triggerPatternPlaceholder': 'Patrón de regex',
'hostDetails.automation.linkedScripts': 'Scripts vinculados',
'hostDetails.automation.linkedScriptsEmpty': 'Aún no hay scripts vinculados a este host.',
'hostDetails.automation.linkScriptPlaceholder': 'Vincular script existente…',
'hostDetails.automation.unlink': 'Desvincular',
'hostDetails.automation.equivalenceHint': 'Los vínculos agregan este host a la lista de destinos del script en Scripts. Ambas vistas se mantienen sincronizadas.',
'hostDetails.automation.queueHint': 'Los scripts se ejecutan en orden cuando este host se conecta. Los scripts globales se ejecutan primero, luego esta cola.',
'hostDetails.automation.globalScripts': 'Scripts de conexión globales',
'hostDetails.automation.globalScriptsHint': 'Se ejecutan primero en cada conexión. Reordena según el orden de clasificación de la biblioteca de Scripts.',
'hostDetails.automation.connectQueue': 'Cola de ejecución de este host',
'hostDetails.automation.connectQueueEmpty': 'Aún no hay scripts de conexión en cola para este host.',
'hostDetails.automation.addToQueuePlaceholder': 'Agregar script a la cola…',
'hostDetails.automation.moveUp': 'Subir',
'hostDetails.automation.moveDown': 'Bajar',
'hostDetails.automation.removeFromQueue': 'Quitar de la cola',
'hostDetails.automation.dragHandle': 'Arrastra para reordenar',
'hostDetails.automation.queueDragHint': 'Arrastra los elementos para reordenar la cola de conexión.',
};
@@ -0,0 +1,261 @@
import type { Messages } from '../types';
export const esSystemManagerMessages: Messages = {
'terminal.layer.system': 'Sistema',
'systemManager.noSession': 'No hay una sesión de terminal activa.',
'systemManager.notConnected': 'Conéctate a un host para administrar procesos y servicios.',
'systemManager.empty': 'No hay datos disponibles.',
'systemManager.tabs.overview': 'Resumen',
'systemManager.tabs.processes': 'Procesos',
'systemManager.tabs.ports': 'Puertos',
'systemManager.tabs.services': 'Servicios',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': 'Secciones del administrador del sistema',
'systemManager.popup.loading': 'Abriendo terminal…',
'systemManager.popup.startupFailed': 'El comando de inicio no se completó correctamente. Verifica que el destino siga disponible e inténtalo de nuevo.',
'systemManager.errors.loadProcesses': 'No se pudieron cargar los procesos',
'systemManager.errors.loadTmux': 'No se pudieron cargar las sesiones de tmux',
'systemManager.errors.loadTmuxWindows': 'No se pudieron cargar las ventanas de tmux',
'systemManager.errors.loadTmuxPanes': 'No se pudieron cargar los paneles de tmux',
'systemManager.errors.loadTmuxClients': 'No se pudieron cargar los clientes de tmux',
'systemManager.errors.actionFailed': 'La acción falló',
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
'systemManager.overview.loading': 'Cargando resumen del sistema…',
'systemManager.overview.memory': 'Memoria',
'systemManager.overview.disk': 'Disco',
'systemManager.overview.network': 'Red',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} núcleos',
'systemManager.overview.load': 'Carga',
'systemManager.overview.uptime': 'Tiempo activo',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'Sistema',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Latencia de red SSH',
'systemManager.overview.cpuCores': 'Núcleos de CPU',
'systemManager.overview.disks': 'Discos',
'systemManager.overview.interfaces': 'Interfaces de red',
'systemManager.overview.topProcesses': 'Procesos con más memoria',
'systemManager.overview.noData': 'Sin datos',
'systemManager.overview.noDisks': 'Sin datos de disco',
'systemManager.overview.noInterfaces': 'Sin datos de interfaces',
'systemManager.overview.noTopProcesses': 'Sin datos de procesos',
'systemManager.processes.search': 'Buscar procesos…',
'systemManager.processes.command': 'Comando',
'systemManager.processes.user': 'Usuario',
'systemManager.processes.term': 'Terminar',
'systemManager.processes.kill': 'Forzar cierre',
'systemManager.processes.stop': 'Detener (SIGSTOP)',
'systemManager.processes.cont': 'Continuar (SIGCONT)',
'systemManager.processes.hup': 'Colgar (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Valor de nice (-20 a 19)',
'systemManager.processes.reniceInvalid': 'El valor de nice debe estar entre -20 y 19',
'systemManager.processes.confirmKill': '¿Enviar SIGKILL al proceso {{pid}}?',
'systemManager.processes.confirmSignal': '¿Enviar SIG{{signal}} al proceso {{pid}}?',
'systemManager.processes.filter.all': 'Todos',
'systemManager.processes.filter.running': 'En ejecución',
'systemManager.processes.ppid': 'PID padre',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Tamaño virtual',
'systemManager.processes.elapsed': 'Transcurrido',
'systemManager.processes.stat': 'Estado',
'systemManager.processes.meta': '{{count}} proceso(s)',
'systemManager.processes.loading': 'Cargando procesos…',
'systemManager.processes.loadingMore': 'Cargando más procesos…',
'systemManager.processes.state.running': 'En ejecución',
'systemManager.processes.state.sleeping': 'En reposo',
'systemManager.processes.state.stopped': 'Detenido',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Comando',
'systemManager.processes.sort.user': 'Usuario',
'systemManager.common.dismiss': 'Descartar',
'systemManager.common.checkingAvailability': 'Verificando disponibilidad…',
'systemManager.common.loading': 'Cargando…',
'systemManager.common.loadingDetails': 'Cargando detalles…',
'systemManager.common.loadingStats': 'Cargando estadísticas…',
'systemManager.tmux.new': 'Nuevo',
'systemManager.tmux.search': 'Buscar sesiones…',
'systemManager.tmux.newSessionTitle': 'Nueva sesión de tmux',
'systemManager.tmux.newSessionDesc': 'Ponle nombre a la sesión y, opcionalmente, ejecuta un script al iniciar.',
'systemManager.tmux.newSessionTabCustom': 'Comando personalizado',
'systemManager.tmux.newSessionTabSnippet': 'Desde snippet',
'systemManager.tmux.pickSnippet': 'Desde snippets',
'systemManager.tmux.pickSnippetEmpty': 'Aún no hay snippets; agrega algunos en el panel de Scripts o en Vault.',
'systemManager.tmux.selectedSnippet': 'Usando snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Nombre de la sesión',
'systemManager.tmux.newSessionCommand': 'Comando de inicio',
'systemManager.tmux.newSessionCommandPlaceholder': 'p. ej. htop o npm run dev (opcional)',
'systemManager.tmux.newSessionCommandHint': 'Déjalo vacío para una sesión de shell predeterminada.',
'systemManager.tmux.creating': 'Creando…',
'systemManager.tmux.newSessionPlaceholder': 'mi-sesion',
'systemManager.tmux.newSessionRequired': 'Primero ingresa un nombre de sesión',
'systemManager.tmux.empty': 'No hay sesiones de tmux',
'systemManager.tmux.attach': 'Conectar',
'systemManager.tmux.attached': 'Conectada',
'systemManager.tmux.detached': 'Desacoplada',
'systemManager.tmux.windows': '{{count}} ventana(s)',
'systemManager.tmux.created': 'Creada',
'systemManager.tmux.activity': 'Actividad',
'systemManager.tmux.rename': 'Renombrar',
'systemManager.tmux.detach': 'Desacoplar todo',
'systemManager.tmux.killSession': 'Terminar sesión',
'systemManager.tmux.killServer': 'Terminar servidor',
'systemManager.tmux.loadingDetails': 'Cargando detalles…',
'systemManager.tmux.clients': 'Clientes conectados',
'systemManager.tmux.windowList': 'Ventanas',
'systemManager.tmux.newWindow': 'Nueva ventana',
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
'systemManager.tmux.noWindows': 'No hay ventanas',
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
'systemManager.tmux.noPanes': 'No hay paneles',
'systemManager.tmux.panes': '{{count}} panel(es)',
'systemManager.tmux.active': 'activo',
'systemManager.tmux.unnamedWindow': 'Ventana sin nombre',
'systemManager.tmux.unnamedPane': 'Panel sin nombre',
'systemManager.tmux.attachWindow': 'Conectar a la ventana',
'systemManager.tmux.selectWindow': 'Seleccionar ventana',
'systemManager.tmux.killWindow': 'Terminar ventana',
'systemManager.tmux.killPane': 'Terminar panel',
'systemManager.tmux.splitHorizontal': 'Dividir horizontal',
'systemManager.tmux.splitVertical': 'Dividir vertical',
'systemManager.tmux.sendKeys': 'Enviar teclas',
'systemManager.tmux.sendKeysTo': 'Enviar teclas a la ventana {{window}} panel {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Comando o texto…',
'systemManager.tmux.renameSessionPrompt': 'Renombrar sesión',
'systemManager.tmux.renameWindowPrompt': 'Renombrar ventana',
'systemManager.tmux.windowName': 'Nombre de la ventana',
'systemManager.tmux.confirmKillSession': '¿Terminar la sesión de tmux "{{name}}"?',
'systemManager.tmux.confirmDetachSession': '¿Desacoplar todos los clientes de "{{name}}"?',
'systemManager.tmux.confirmKillWindow': '¿Terminar la ventana "{{name}}"?',
'systemManager.tmux.confirmKillPane': '¿Terminar el panel #{{index}}?',
'systemManager.tmux.confirmKillServer': '¿Terminar el servidor de tmux? Se terminarán todas las sesiones.',
'systemManager.tmux.meta': '{{count}} sesión(es)',
'systemManager.docker.title': 'Contenedores',
'systemManager.docker.subTabs.containers': 'Contenedores',
'systemManager.docker.subTabs.images': 'Imágenes',
'systemManager.docker.empty': 'No se encontraron contenedores',
'systemManager.docker.imagesEmpty': 'No se encontraron imágenes',
'systemManager.docker.search': 'Buscar contenedores…',
'systemManager.docker.searchImages': 'Buscar imágenes…',
'systemManager.docker.filter.all': 'Todos',
'systemManager.docker.filter.running': 'En ejecución',
'systemManager.docker.filter.stopped': 'Detenidos',
'systemManager.docker.filter.paused': 'En pausa',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Registros',
'systemManager.docker.details': 'Detalles',
'systemManager.docker.inspect': 'Inspeccionar',
'systemManager.docker.imageInspect': 'Inspección de imagen',
'systemManager.docker.confirmRemove': '¿Eliminar este contenedor?',
'systemManager.docker.confirmKill': '¿Terminar por la fuerza este contenedor?',
'systemManager.docker.confirmRemoveImage': '¿Eliminar la imagen "{{name}}"?',
'systemManager.docker.confirmPrune': '¿Eliminar las imágenes colgantes?',
'systemManager.docker.confirmPruneAll': '¿Eliminar todas las imágenes no utilizadas?',
'systemManager.docker.pause': 'Pausar',
'systemManager.docker.unpause': 'Reanudar',
'systemManager.docker.restart': 'Reiniciar',
'systemManager.docker.kill': 'Terminar',
'systemManager.docker.renamePrompt': 'Nombre del contenedor',
'systemManager.docker.prune': 'Limpiar',
'systemManager.docker.pruneAll': 'Limpiar todo',
'systemManager.docker.tag': 'Etiqueta',
'systemManager.docker.tagRepoPrompt': 'Nombre del repositorio',
'systemManager.docker.tagNamePrompt': 'Nombre de la etiqueta',
'systemManager.docker.meta': '{{count}} contenedor(es)',
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
'systemManager.docker.start': 'Iniciar',
'systemManager.docker.stop': 'Detener',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
'systemManager.ports.empty': 'No se reportaron puertos en escucha.',
'systemManager.ports.search': 'Buscar puertos…',
'systemManager.ports.meta': '{{count}} en escucha',
'systemManager.ports.filter.all': 'Todos',
'systemManager.ports.unknownProcess': 'Proceso desconocido',
'systemManager.ports.terminate': 'Terminar',
'systemManager.ports.confirmTerminate': '¿Enviar SIGTERM al proceso {{pid}} que ocupa este puerto?',
'systemManager.services.unavailable': 'systemctl no está disponible en este host.',
'systemManager.services.loading': 'Cargando servicios de systemd…',
'systemManager.services.empty': 'No se encontraron servicios de systemd.',
'systemManager.services.search': 'Buscar servicios…',
'systemManager.services.meta': '{{count}} servicio(s)',
'systemManager.services.filter.all': 'Todos',
'systemManager.services.filter.running': 'En ejecución',
'systemManager.services.filter.failed': 'Con error',
'systemManager.services.filter.inactive': 'Inactivo',
'systemManager.services.start': 'Iniciar',
'systemManager.services.stop': 'Detener',
'systemManager.services.restart': 'Reiniciar',
'systemManager.services.enable': 'Habilitar',
'systemManager.services.disable': 'Deshabilitar',
'systemManager.services.reload': 'Recargar',
'systemManager.services.scope.user': 'usuario',
'systemManager.services.confirmAction': '¿{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'No se detectaron herramientas de GPU NVIDIA o NPU Ascend en este host.',
'systemManager.gpu.loading': 'Cargando estadísticas del acelerador…',
'systemManager.gpu.empty': 'Las herramientas del acelerador están presentes, pero no se reportó ningún dispositivo.',
'systemManager.gpu.meta': '{{devices}} dispositivo(s) · {{processes}} proceso(s)',
'systemManager.gpu.devices': 'Dispositivos',
'systemManager.gpu.processes': 'Procesos de cómputo',
'systemManager.gpu.noProcesses': 'No se reportaron procesos de cómputo.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Util',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Temperatura',
'systemManager.gpu.power': 'Energía',
'systemManager.gpu.fan': 'Ventilador {{value}}%',
'systemManager.gpu.driver': 'Controlador {{version}}',
'systemManager.inspect.status': 'Estado',
'systemManager.inspect.image': 'Imagen',
'systemManager.inspect.created': 'Creado',
'systemManager.inspect.started': 'Iniciado',
'systemManager.inspect.restartPolicy': 'Política de reinicio',
'systemManager.inspect.command': 'Comando',
'systemManager.inspect.ports': 'Puertos',
'systemManager.inspect.networks': 'Redes',
'systemManager.inspect.mounts': 'Montajes',
'systemManager.inspect.env': 'Entorno',
'systemManager.inspect.labels': 'Etiquetas',
'systemManager.inspect.tags': 'Etiquetas',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Tamaño',
'systemManager.inspect.platform': 'Plataforma',
'systemManager.inspect.workdir': 'Directorio de trabajo',
'systemManager.inspect.exposedPorts': 'Puertos expuestos',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Ocultar JSON',
};
+847
View File
@@ -0,0 +1,847 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'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',
// Network Device Mode auto-detection tip (session header)
'terminal.networkDevice.tip.message': 'Esto parece un dispositivo de red. Activa el Modo de Dispositivo de Red para enviar comandos tal cual (sin envoltorio de shell).',
'terminal.networkDevice.tip.action': 'Activar',
'terminal.networkDevice.tip.dismiss': 'Descartar',
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Abrir SFTP',
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Más acciones',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Historial de comandos',
'terminal.toolbar.configureOsc7': 'Configurar seguimiento de directorio',
'history.scope.label': 'Alcance del historial',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Buscar en el historial...',
'history.loading': 'Cargando historial remoto...',
'history.meta.count': '{count} comandos',
'history.empty.noSession': 'Abre una sesión remota para ver su historial de comandos.',
'history.empty.unsupportedProtocol': 'El historial de comandos solo está disponible para sesiones SSH/Mosh/ET.',
'history.empty.noHistory': 'No se encontró historial de comandos en este host.',
'history.empty.noGlobalHistory': 'Aún no hay historial global de comandos. Los comandos que ejecutes aparecerán aquí.',
'history.action.refresh': 'Actualizar',
'history.action.retry': 'Reintentar',
'history.action.paste': 'Pegar en la terminal',
'history.action.run': 'Ejecutar en la terminal',
'history.action.saveAsSnippet': 'Guardar como snippet',
'history.action.delete': 'Eliminar del historial',
'terminal.toolbar.library': 'Biblioteca',
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
'terminal.toolbar.searchTerminal': 'Buscar en la terminal',
'terminal.toolbar.search': 'Buscar',
'terminal.toolbar.startSessionLog': 'Iniciar registro de sesión',
'terminal.toolbar.stopSessionLog': 'Detener registro de sesión',
'terminal.toolbar.timestampsEnable': 'Mostrar marcas de tiempo',
'terminal.toolbar.timestampsDisable': 'Ocultar marcas de tiempo',
'terminal.toolbar.broadcast': 'Difundir',
'terminal.toolbar.broadcastEnable': 'Activar Modo de Difusión',
'terminal.toolbar.broadcastDisable': 'Desactivar Modo de Difusión',
'terminal.toolbar.composeBar': 'Barra de redacción',
'terminal.composeBar.placeholder': 'Escribe el comando aquí y presiona Enter para enviar...',
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
'terminal.composeBar.noPinnedSnippets': 'Fija snippets con + para acceso rápido',
'terminal.composeBar.noMatchingSnippets': 'No hay snippets que coincidan',
'terminal.composeBar.pinnedCount': '{count} fijados',
'terminal.composeBar.unpinSnippet': 'Quitar {label} de la barra rápida',
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
'terminal.toolbar.focus': 'Enfocar',
'terminal.toolbar.focusMode': 'Modo de Enfoque',
'terminal.paneMagnification.magnify': 'Ampliar panel actual',
'terminal.paneMagnification.restore': 'Restaurar diseño de paneles',
'terminal.paneMagnification.hint': 'Ampliado',
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
'terminal.toolbar.encoding': 'Codificación de la terminal',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Cerrar sesión',
'terminal.toolbar.hostHighlight.title': 'Resaltado de palabras clave del host',
'terminal.toolbar.hostHighlight.noRules': 'No hay reglas de resaltado personalizadas definidas para este host',
'terminal.toolbar.hostHighlight.addRule': 'Agregar nueva regla',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Etiqueta (p. ej., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Patrón regex (p. ej., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Patrón regex no válido',
'terminal.toolbar.hostHighlight.clearAll': 'Borrar todo',
'terminal.toolbar.hostHighlight.changeColor': 'Cambiar el color de resaltado de',
'terminal.toolbar.hostHighlight.selectColor': 'Seleccionar el color de la nueva regla',
'terminal.statusbar.copyHostname.label': 'Copiar dirección del host',
'terminal.statusbar.copyHostname.tooltip': 'Copiar dirección del host ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Dirección del host copiada: {hostname}',
'terminal.statusbar.copyHostname.error': 'No se pudo copiar la dirección del host al portapapeles',
'terminal.statusbar.disconnect.label': 'Desconectar',
'terminal.statusbar.disconnect.tooltip': 'Desconectar esta sesión sin cerrar la pestaña',
'terminal.statusbar.reconnect.label': 'Reconectar',
'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.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
'terminal.serverStats.memBuffers': 'Búfers',
'terminal.serverStats.memCached': 'Caché',
'terminal.serverStats.memFree': 'Libre',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap usada',
'terminal.serverStats.swapFree': 'Swap libre',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Principales procesos por memoria',
'terminal.serverStats.disk': 'Uso de disco',
'terminal.serverStats.diskDetails': 'Discos montados',
'terminal.serverStats.network': 'Velocidad de red',
'terminal.serverStats.latency': 'Latencia de red SSH',
'terminal.serverStats.networkDetails': 'Interfaces de red',
'terminal.serverStats.noData': 'No hay datos disponibles',
'terminal.dragDrop.localTitle': 'Soltar para insertar rutas',
'terminal.dragDrop.localMessage': 'Las rutas de los archivos se insertarán en la terminal',
'terminal.dragDrop.remoteTitle': 'Soltar para subir archivos',
'terminal.dragDrop.remoteZmodemMessage': 'Los archivos se subirán mediante ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Los archivos se subirán mediante SFTP',
'terminal.dragDrop.noFiles': 'No hay archivos para subir',
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
'terminal.dragDrop.errorTitle': 'Error al soltar',
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
'terminal.search.placeholder': 'Buscar...',
'terminal.search.noResults': 'Sin resultados',
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
'terminal.menu.copy': 'Copiar',
'terminal.menu.paste': 'Pegar',
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
'terminal.menu.addSelectionToAI': 'Agregar a la conversación',
'terminal.menu.pasteSelection': 'Pegar selección',
'terminal.menu.selectAll': 'Seleccionar todo',
'terminal.menu.reconnect': 'Reconectar',
'terminal.menu.sendYmodem': 'Enviar con YMODEM',
'terminal.menu.receiveYmodem': 'Recibir con YMODEM',
'terminal.menu.splitHorizontal': 'Dividir en horizontal',
'terminal.menu.splitVertical': 'Dividir en vertical',
'terminal.menu.clearBuffer': 'Limpiar búfer',
'terminal.menu.closeTerminal': 'Cerrar terminal',
'terminal.menu.rename': 'Renombrar',
'terminal.menu.detach': 'Desacoplar del espacio de trabajo',
'terminal.menu.detachSession': 'Desacoplar {name}',
'terminal.clipboardImageUpload.noImage': 'El portapapeles no contiene una imagen',
'terminal.clipboardImageUpload.failed': 'No se pudo subir la imagen del portapapeles',
'terminal.osc7Setup.title': 'Configurar seguimiento de directorio',
'terminal.osc7Setup.desc': 'Netcatty agregará hooks de prompt OSC 7 para el usuario remoto actual. Esto ayuda a que SFTP siga el directorio de la terminal después de sudo o su.',
'terminal.osc7Setup.targets': 'Posibles archivos a actualizar',
'terminal.osc7Setup.command': 'Comando a ejecutar',
'terminal.osc7Setup.run': 'Ejecutar configuración',
'terminal.osc7Setup.running': 'Configurando...',
'terminal.osc7Setup.configured': 'Seguimiento de directorio configurado',
'terminal.osc7Setup.failed': 'Falló la configuración del seguimiento de directorio',
'terminal.osc7Setup.sent': 'Configuración de seguimiento de directorio enviada a la terminal',
'terminal.ymodem.selectFile': 'Selecciona el archivo a enviar',
'terminal.ymodem.allFiles': 'Todos los archivos',
'terminal.ymodem.started': 'YMODEM enviando {fileName}',
'terminal.ymodem.complete': 'YMODEM envió {fileName}',
'terminal.ymodem.failed': 'Falló el envío con YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Selecciona la carpeta para guardar los archivos recibidos',
'terminal.ymodem.receiveStarted': 'YMODEM recibiendo...',
'terminal.ymodem.receiveComplete': 'YMODEM recibió {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM recibió {count} archivos',
'terminal.ymodem.receiveEmpty': 'No se recibieron archivos YMODEM',
'terminal.ymodem.receiveFailed': 'Falló la recepción con YMODEM',
'terminal.ymodem.unavailable': 'YMODEM no está disponible',
'terminal.selection.addToAI': 'Agregar a la conversación',
'terminal.selection.addToAIDesc': 'Adjuntar la salida seleccionada de la terminal al borrador de IA',
'terminal.auth.password': 'Contraseña',
'terminal.auth.sshKey': 'Clave SSH',
'terminal.auth.username': 'Nombre de usuario',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Contraseña',
'terminal.auth.password.placeholder': 'Ingresa la contraseña',
'terminal.auth.passphrase': 'Frase de contraseña',
'terminal.auth.passphrase.placeholder': 'Frase de contraseña opcional para la clave privada seleccionada',
'terminal.auth.certificate': 'Certificado',
'terminal.auth.selectKey': 'Seleccionar clave',
'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.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.',
'terminal.auth.proxyCredentialsUnavailable': 'Las credenciales del proxy no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresar la contraseña del proxy.',
'terminal.auth.keyUnavailableFallbackPassword': 'La clave SSH guardada no está disponible en este dispositivo. Se usará autenticación por contraseña como respaldo.',
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
'terminal.progress.reconnecting': 'Reconectando...',
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
'terminal.connection.chainOf': 'Cadena {current} de {total}',
'terminal.connection.showLogs': 'Mostrar registros',
'terminal.connection.hideLogs': 'Ocultar registros',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.connection.protocol.plugin': 'Conexión de plugin',
'terminal.et.proxyUnsupported': 'EternalTerminal no admite actualmente la configuración de proxy de Netcatty. Usa SSH o quita el proxy de este host.',
'terminal.et.multiJumpUnsupported': 'EternalTerminal admite actualmente como máximo un host de salto en Netcatty.',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Shell local',
'terminal.hostKey.unknownTitle': 'Confirmar esta clave de host',
'terminal.hostKey.changedTitle': 'La clave del host cambió',
'terminal.hostKey.unknownDescription': 'Aún no se puede establecer la autenticidad de {host}.',
'terminal.hostKey.changedDescription': 'La clave guardada de {host} ya no coincide con este servidor.',
'terminal.hostKey.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Huella guardada',
'terminal.hostKey.unknownHint': 'Recuérdala si esta huella pertenece al servidor que esperabas.',
'terminal.hostKey.changedHint': 'Continúa solo si esperabas que este host cambiara.',
'terminal.hostKey.addAndContinue': 'Agregar y continuar',
'terminal.hostKey.updateAndContinue': 'Actualizar y continuar',
'terminal.themeModal.title': 'Apariencia de la terminal',
'terminal.themeModal.tab.theme': 'Tema',
'terminal.themeModal.tab.font': 'Fuente',
'terminal.themeModal.tab.custom': 'Personalizado',
'terminal.themeModal.globalTheme': 'Tema global',
'terminal.themeModal.globalFont': 'Fuente global',
'terminal.themeModal.fontSize': 'Tamaño de fuente',
'terminal.themeModal.fontWeight': 'Grosor de fuente',
'terminal.themeModal.livePreview': 'Vista previa en vivo',
'terminal.themeModal.themeType': 'Tema {type}',
'terminal.hiddenTheme.title': 'Tema oculto actual',
'terminal.hiddenTheme.desc': 'Este tema está oculto de la selección manual y se reemplazará cuando elijas otro tema.',
'topTabs.toggleTheme.systemExitTitle': 'El tema del sistema está activo',
'topTabs.toggleTheme.systemExitMessage': 'Abre Configuración para elegir un tema fijo de Claro u Oscuro.',
'topTabs.toggleTheme.openSettings': 'Abrir Configuración',
// Custom Themes
'terminal.customTheme.section': 'Temas personalizados',
'terminal.customTheme.yourThemes': 'Tus temas',
'terminal.customTheme.new': 'Nuevo tema',
'terminal.customTheme.newDesc': 'Clonar el tema actual y personalizarlo',
'terminal.customTheme.newTitle': 'Nuevo tema personalizado',
'terminal.customTheme.editTitle': 'Editar tema',
'terminal.customTheme.import': 'Importar .itermcolors',
'terminal.customTheme.importDesc': 'Importar desde archivo de esquema de color de iTerm2',
'terminal.customTheme.importError': 'No se pudo analizar el archivo seleccionado. Asegúrate de que sea un archivo XML .itermcolors válido.',
'terminal.customTheme.delete': 'Eliminar tema',
'terminal.customTheme.confirmDelete': 'Confirmar eliminación',
'terminal.customTheme.name': 'Nombre',
'terminal.customTheme.namePlaceholder': 'Mi tema personalizado',
'terminal.customTheme.type': 'Tipo',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Colores normales',
'terminal.customTheme.group.bright': 'Colores brillantes',
'terminal.customTheme.color.background': 'Fondo',
'terminal.customTheme.color.foreground': 'Primer plano',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selección',
'terminal.customTheme.color.black': 'Negro',
'terminal.customTheme.color.red': 'Rojo',
'terminal.customTheme.color.green': 'Verde',
'terminal.customTheme.color.yellow': 'Amarillo',
'terminal.customTheme.color.blue': 'Azul',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cian',
'terminal.customTheme.color.white': 'Blanco',
'terminal.customTheme.color.brightBlack': 'Negro brillante',
'terminal.customTheme.color.brightRed': 'Rojo brillante',
'terminal.customTheme.color.brightGreen': 'Verde brillante',
'terminal.customTheme.color.brightYellow': 'Amarillo brillante',
'terminal.customTheme.color.brightBlue': 'Azul brillante',
'terminal.customTheme.color.brightMagenta': 'Magenta brillante',
'terminal.customTheme.color.brightCyan': 'Cian brillante',
'terminal.customTheme.color.brightWhite': 'Blanco brillante',
// Cloud Sync Settings
'cloudSync.gate.title': 'Sincronización cifrada de extremo a extremo',
'cloudSync.gate.desc':
'Tus datos se cifran localmente antes de sincronizarse. Los proveedores de la nube nunca ven tus datos en texto plano. Configura una clave maestra para habilitar la sincronización segura.',
'cloudSync.gate.masterKey': 'Clave maestra',
'cloudSync.gate.confirmMasterKey': 'Confirmar clave maestra',
'cloudSync.gate.placeholder': 'Ingresa una contraseña segura',
'cloudSync.gate.confirmPlaceholder': 'Confirma tu contraseña',
'cloudSync.gate.mismatch': 'Las contraseñas no coinciden',
'cloudSync.gate.warning':
'Entiendo que, si olvido mi clave maestra, mis datos no se podrán recuperar. No hay restablecimiento de contraseña.',
'cloudSync.gate.enableVault': 'Habilitar bóveda cifrada',
'cloudSync.gate.enabledToast': 'Bóveda cifrada habilitada',
'cloudSync.gate.setupFailed': 'No se pudo configurar la clave maestra',
'cloudSync.passwordStrength.tooShort': 'Demasiado corta',
'cloudSync.passwordStrength.weak': 'Débil',
'cloudSync.passwordStrength.moderate': 'Moderada',
'cloudSync.passwordStrength.strong': 'Segura',
'cloudSync.passwordStrength.veryStrong': 'Muy segura',
'cloudSync.provider.notConnected': 'No conectado',
'cloudSync.provider.sync': 'Sincronizar',
'cloudSync.provider.connect': 'Conectar',
'cloudSync.provider.connecting': 'Conectando...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
'cloudSync.provider.s3': 'Compatible con S3',
'cloudSync.provider.s3.desc': 'Conectar a almacenamiento de objetos compatible con S3',
'cloudSync.provider.comingSoon': 'Próximamente',
'cloudSync.webdav.title': 'Configuración de WebDAV',
'cloudSync.webdav.desc': 'Configura un endpoint WebDAV para la sincronización cifrada.',
'cloudSync.webdav.endpoint': 'URL del endpoint',
'cloudSync.webdav.authType': 'Tipo de autenticación',
'cloudSync.webdav.auth.basic': 'Básica',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': 'Nombre de usuario',
'cloudSync.webdav.password': 'Contraseña',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Mostrar secreto',
'cloudSync.webdav.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
'cloudSync.webdav.validation.endpoint': 'Ingresa un endpoint WebDAV válido.',
'cloudSync.webdav.validation.credentials': 'El nombre de usuario y la contraseña son obligatorios.',
'cloudSync.webdav.validation.token': 'El token es obligatorio.',
'cloudSync.s3.title': 'Configuración de S3',
'cloudSync.s3.desc': 'Conéctate al almacenamiento de objetos compatible con S3 para la sincronización cifrada.',
'cloudSync.s3.endpoint': 'URL del endpoint',
'cloudSync.s3.region': 'Región',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'ID de clave de acceso',
'cloudSync.s3.secretAccessKey': 'Clave de acceso secreta',
'cloudSync.s3.sessionToken': 'Token de sesión (opcional)',
'cloudSync.s3.prefix': 'Prefijo de clave (opcional)',
'cloudSync.s3.forcePathStyle': 'Forzar URLs con estilo de ruta (para MinIO/R2, etc.)',
'cloudSync.s3.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
'cloudSync.s3.showSecret': 'Mostrar secretos',
'cloudSync.s3.validation.required': 'Endpoint, región, bucket, clave de acceso y secreto son obligatorios.',
'cloudSync.smb.title': 'Configuración de SMB',
'cloudSync.smb.desc': 'Conéctate a un recurso compartido de archivos SMB/CIFS para la sincronización cifrada.',
'cloudSync.smb.share': 'Ruta del recurso compartido',
'cloudSync.smb.username': 'Nombre de usuario',
'cloudSync.smb.password': 'Contraseña',
'cloudSync.smb.domain': 'Dominio (opcional)',
'cloudSync.smb.domainPlaceholder': 'p. ej., WORKGROUP',
'cloudSync.smb.port': 'Puerto (opcional)',
'cloudSync.smb.showSecret': 'Mostrar contraseña',
'cloudSync.smb.validation.share': 'La ruta del recurso compartido es obligatoria.',
'cloudSync.smb.validation.port': 'El puerto debe ser un número entre 1 y 65535.',
'cloudSync.connect.smb.success': 'SMB se conectó correctamente',
'cloudSync.connect.smb.failedTitle': 'Falló la conexión a SMB',
'cloudSync.provider.smb': 'Recurso compartido SMB',
'cloudSync.connect.webdav.success': 'WebDAV se conectó correctamente',
'cloudSync.connect.webdav.failedTitle': 'Falló la conexión a WebDAV',
'cloudSync.connect.s3.success': 'S3 se conectó correctamente',
'cloudSync.connect.s3.failedTitle': 'Falló la conexión a S3',
'cloudSync.connect.plugin.success': 'El proveedor de sincronización de plugin se conectó correctamente',
'cloudSync.connect.plugin.failedTitle': 'Falló la conexión del plugin de sincronización',
'cloudSync.pluginConfig.title': 'Configurar {name}',
'cloudSync.pluginConfig.desc': 'Ingresa la configuración JSON requerida por este proveedor de sincronización de plugin.',
'cloudSync.pluginConfig.label': 'Configuración del proveedor (JSON)',
'cloudSync.pluginConfig.invalidJson': 'La configuración debe ser JSON válido.',
'cloudSync.pluginConfig.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
'cloudSync.lastSync.never': 'Nunca',
'cloudSync.lastSync.justNow': 'Justo ahora',
'cloudSync.lastSync.minutesAgo': 'Hace {minutes} min',
'cloudSync.changeKey': 'Cambiar clave',
'cloudSync.providers.title': 'Proveedores de nube',
'cloudSync.syncAll': 'Sincronizar todos los proveedores conectados',
'cloudSync.autoSync.title': 'Sincronización automática',
'cloudSync.autoSync.desc': 'Sincronizar automáticamente cuando se realicen cambios',
'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.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.',
'cloudSync.strategy.preferLocal': 'Gana lo local',
'cloudSync.strategy.preferLocalDesc': 'Cuando ambos lados cambiaron, sube la versión local y reemplaza los cambios de la nube.',
'cloudSync.convergent.title': 'Sincronización convergente de múltiples dispositivos',
'cloudSync.convergent.experimental': 'Experimental',
'cloudSync.convergent.desc': 'Usa una réplica CRDT cifrada para preservar ediciones sin conexión, eliminaciones concurrentes y cambios de cada proveedor conectado.',
'cloudSync.convergent.active': 'CRDT v2 está activo. Las escrituras del proveedor se verifican después de la subida.',
'cloudSync.convergent.paused': 'CRDT v2 está en pausa en este dispositivo; los metadatos de la nube se conservan.',
'cloudSync.convergent.enabled': 'Sincronización convergente habilitada',
'cloudSync.convergent.preview.title': 'Vista previa de migración',
'cloudSync.convergent.preview.entities': 'Entidades',
'cloudSync.convergent.preview.providers': 'Proveedores',
'cloudSync.convergent.preview.conflicts': 'Conflictos',
'cloudSync.convergent.preview.compatibility': 'Una instantánea v1 completa permanece en cada payload cifrado para clientes más antiguos.',
'cloudSync.convergent.preview.confirm': 'Crear réplica CRDT',
'cloudSync.convergent.preview.status.ready': 'Listo',
'cloudSync.convergent.preview.status.empty': 'Vacío',
'cloudSync.convergent.preview.status.unavailable': 'No disponible',
'cloudSync.convergent.preview.status.blocked': 'Bloqueado',
'cloudSync.convergent.preview.schema': 'esquema',
'cloudSync.convergent.field.presence': 'presencia',
'cloudSync.convergent.field.position': 'posición',
'cloudSync.convergent.conflicts.title': 'Conflictos de campo ({count})',
'cloudSync.convergent.conflict.empty': 'Vacío / eliminado',
'cloudSync.convergent.conflict.secretSet': 'El secreto está establecido',
'cloudSync.convergent.conflict.current': 'ganador actual',
'cloudSync.convergent.conflict.choose': 'Elegir',
'cloudSync.convergent.conflict.resolved': 'Conflicto resuelto y sincronizado',
'cloudSync.convergent.downgrade.desc': 'Reemplaza los archivos v2 de cada proveedor conectado con una instantánea heredada.',
'cloudSync.convergent.downgrade.button': 'Degradar',
'cloudSync.convergent.downgrade.confirm': '¿Degradar cada proveedor conectado a la sincronización heredada? Esto elimina los metadatos CRDT después de la verificación de escritura.',
'cloudSync.convergent.downgrade.done': 'Sincronización convergente degradada',
'cloudSync.status.title': 'Estado de sincronización',
'cloudSync.status.localVersion': 'Versión local',
'cloudSync.status.remoteVersion': 'Versión remota',
'cloudSync.history.title': 'Historial de sincronización',
'cloudSync.history.upload': 'Subida',
'cloudSync.history.download': 'Descarga',
'cloudSync.history.resolved': 'Resuelto',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas',
'cloudSync.localBackups.maxSaved': 'Retención de copia guardada: {count}',
'cloudSync.localBackups.maxInvalid': 'Ingresa un número entre 1 y 100.',
'cloudSync.localBackups.empty': 'Aún no hay copias de seguridad locales.',
'cloudSync.localBackups.reason.appVersionChange': 'Antes del cambio de versión de la app',
'cloudSync.localBackups.reason.beforeRestore': 'Antes de la restauración',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} claves, {snippets} snippets, {notes} notas',
'cloudSync.localBackups.restore': 'Restaurar',
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
'cloudSync.localBackups.restoreConfirmButton': 'Restaurar',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancelar',
'cloudSync.localBackups.unavailableTitle': 'Copias de seguridad locales no disponibles',
'cloudSync.localBackups.unavailableDesc': 'Esta plataforma no expone un keychain seguro a Netcatty, por lo que las copias de seguridad locales no se pueden escribir de forma segura. Instala Netcatty en un sistema con un keychain compatible para habilitar el historial de copias de seguridad locales.',
'cloudSync.localBackups.lockedTitle': 'Se requiere clave maestra',
'cloudSync.localBackups.lockedDesc': 'Configura o desbloquea tu clave maestra antes de restaurar una copia de seguridad, para que las credenciales restauradas permanezcan cifradas.',
'cloudSync.revisionHistory.viewButton': 'Historial',
'cloudSync.revisionHistory.title': 'Historial de versiones de la bóveda',
'cloudSync.revisionHistory.description': 'Explora y restaura versiones anteriores de tu bóveda desde el historial de revisiones de Gist.',
'cloudSync.revisionHistory.empty': 'No se encontraron revisiones.',
'cloudSync.revisionHistory.current': 'Actual',
'cloudSync.revisionHistory.revision': 'Revisión',
'cloudSync.revisionHistory.revisionPreview': 'Contenido de la revisión',
'cloudSync.revisionHistory.device': 'Dispositivo',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Claves',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.notes': 'Notas',
'cloudSync.revisionHistory.identities': 'Identidades',
'cloudSync.revisionHistory.restoreButton': 'Restaurar esta versión',
'cloudSync.revisionHistory.restored': 'Bóveda restaurada desde la revisión seleccionada.',
'cloudSync.revisionHistory.revisionNotFound': 'Revisión no encontrada o no contiene datos de la bóveda.',
'cloudSync.revisionHistory.decryptFailed': 'No se puede descifrar esta revisión. Puede que se haya cifrado con una contraseña maestra diferente.',
'cloudSync.changeKey.title': 'Cambiar clave maestra',
'cloudSync.changeKey.current': 'Clave maestra actual',
'cloudSync.changeKey.new': 'Nueva clave maestra',
'cloudSync.changeKey.confirmNew': 'Confirmar nueva clave maestra',
'cloudSync.changeKey.currentPlaceholder': 'Ingresa la clave maestra actual',
'cloudSync.changeKey.newPlaceholder': 'Ingresa la nueva clave maestra',
'cloudSync.changeKey.confirmPlaceholder': 'Confirma la nueva clave maestra',
'cloudSync.changeKey.fillAll': 'Completa todos los campos',
'cloudSync.changeKey.minLength': 'La nueva clave maestra debe tener al menos 8 caracteres',
'cloudSync.changeKey.notMatch': 'Las nuevas claves maestras no coinciden',
'cloudSync.changeKey.incorrectCurrent': 'Clave maestra actual incorrecta',
'cloudSync.changeKey.failed': 'No se pudo cambiar la clave maestra',
'cloudSync.changeKey.desc': 'Esto volverá a cifrar tu bóveda. Asegúrate de recordar la nueva clave.',
'cloudSync.changeKey.showKeys': 'Mostrar claves',
'cloudSync.changeKey.updatedToast': 'Clave maestra actualizada',
'cloudSync.changeKey.updateButton': 'Actualizar clave',
'cloudSync.unlock.title': 'Ingresa la clave maestra',
'cloudSync.unlock.masterKey': 'Clave maestra',
'cloudSync.unlock.desc':
'Ingresa tu clave maestra una vez para habilitar la sincronización cifrada. Se almacenará de forma segura usando el keychain de tu sistema operativo.',
'cloudSync.unlock.placeholder': 'Ingresa tu clave maestra',
'cloudSync.unlock.empty': 'Ingresa tu clave maestra',
'cloudSync.unlock.incorrect': 'Clave maestra incorrecta',
'cloudSync.unlock.failed': 'No se pudo desbloquear la bóveda',
'cloudSync.unlock.showKey': 'Mostrar clave',
'cloudSync.unlock.notNow': 'Ahora no',
'cloudSync.unlock.readyToast': 'Bóveda lista',
'cloudSync.unlock.unlockButton': 'Desbloquear',
'cloudSync.header.vaultReady': 'Bóveda lista',
'cloudSync.header.preparingVault': 'Preparando bóveda...',
'cloudSync.header.providersConnected': '{count} proveedor(es) conectado(s)',
'cloudSync.githubFlow.title': 'Conectar con GitHub',
'cloudSync.githubFlow.desc': 'Copia el código de abajo e ingrésalo en GitHub para autorizar a Netcatty.',
'cloudSync.githubFlow.copyCode': 'Copiar código',
'cloudSync.githubFlow.copied': '¡Copiado!',
'cloudSync.githubFlow.openGitHub': 'Abrir GitHub',
'cloudSync.githubFlow.waiting': 'Esperando autorización...',
'cloudSync.conflict.title': 'Se detectó un conflicto de versiones',
'cloudSync.conflict.desc': 'Elige qué versión conservar',
'cloudSync.conflict.local': 'LOCAL',
'cloudSync.conflict.cloud': 'NUBE',
'cloudSync.conflict.detailsTitle': 'Datos cambiados',
'cloudSync.conflict.detailsCounts': 'Local {local} · Nube {cloud} · Conflictos {conflicts}',
'cloudSync.conflict.entity.hosts': 'Hosts',
'cloudSync.conflict.entity.keys': 'Claves',
'cloudSync.conflict.entity.identities': 'Identidades',
'cloudSync.conflict.entity.proxyProfiles': 'Perfiles de proxy',
'cloudSync.conflict.entity.snippets': 'Snippets',
'cloudSync.conflict.entity.notes': 'Notas',
'cloudSync.conflict.entity.noteGroups': 'Grupos de notas',
'cloudSync.conflict.entity.customGroups': 'Grupos',
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
'cloudSync.conflict.entity.settings': 'Configuración',
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
'cloudSync.conflict.useCloud': 'Descargar la nube (sobrescribir local)',
'cloudSync.connect.browserContinue': 'Completar la autorización en el navegador',
'cloudSync.connect.browserCancelled': 'La autorización anterior del navegador fue cancelada',
'cloudSync.connect.github.success': 'GitHub se conectó correctamente',
'cloudSync.connect.github.failedTitle': 'Falló la conexión a GitHub',
'cloudSync.connect.github.timeout': 'Se agotó el tiempo de espera de la conexión a GitHub. Verifica tu red o la configuración del proxy.',
'cloudSync.connect.github.networkError': 'No se puede acceder a GitHub. Verifica tu red o la configuración del proxy.',
'cloudSync.connect.google.failedTitle': 'Falló la conexión a Google',
'cloudSync.connect.onedrive.failedTitle': 'Falló la conexión a OneDrive',
'cloudSync.sync.success': 'Sincronizado con {provider}',
'cloudSync.sync.failed': 'Falló la sincronización',
'cloudSync.sync.failedTitle': 'Falló la sincronización',
'cloudSync.sync.errorTitle': 'Error de sincronización',
'cloudSync.resolve.downloaded': 'Datos de la nube descargados',
'cloudSync.resolve.uploaded': 'Datos locales subidos',
'cloudSync.resolve.failedTitle': 'Falló la resolución del conflicto',
'cloudSync.clearLocal.title': 'Borrar datos locales',
'cloudSync.clearLocal.desc': 'Restablece la versión local y el historial de sincronización. La próxima sincronización descargará desde la nube.',
'cloudSync.clearLocal.button': 'Borrar',
'cloudSync.clearLocal.dialog.title': '¿Borrar los datos locales de la bóveda?',
'cloudSync.clearLocal.dialog.desc': 'Esto restablecerá la versión local a 0 y borrará el historial de sincronización. Tu próxima sincronización descargará los datos de la nube, reemplazando los datos locales.',
'cloudSync.clearLocal.dialog.cancel': 'Cancelar',
'cloudSync.clearLocal.dialog.confirm': 'Borrar datos locales',
'cloudSync.clearLocal.toast.title': 'Datos locales borrados',
'cloudSync.clearLocal.toast.desc': 'Versión local restablecida a 0. Sincroniza para descargar desde la nube.',
// Keychain
'keychain.filter.key': 'CLAVE',
'keychain.filter.certificate': 'CERTIFICADO',
'keychain.action.generateKey': 'Generar clave',
'keychain.action.importKey': 'Importar clave',
'keychain.action.newIdentity': 'Nueva identidad',
'keychain.action.importCertificate': 'Importar certificado',
'keychain.view.grid': 'Cuadrícula',
'keychain.view.list': 'Lista',
'keychain.section.keys': 'Claves',
'keychain.section.identities': 'Identidades',
'keychain.count.items': '{count} elementos',
'keychain.empty.title': 'Configura tus claves',
'keychain.empty.desc': 'Importa o genera claves SSH para una autenticación segura.',
'keychain.panel.generateKey': 'Generar clave',
'keychain.panel.newKey': 'Nueva clave',
'keychain.panel.keyDetails': 'Detalles de la clave',
'keychain.panel.editKey': 'Editar clave',
'keychain.panel.editIdentity': 'Editar identidad',
'keychain.panel.newIdentity': 'Nueva identidad',
'keychain.panel.keyExport': 'Exportación de clave',
'keychain.validation.labelRequired': 'Ingresa una etiqueta para la clave',
'keychain.validation.labelAndPrivateKeyRequired': 'La etiqueta y la clave privada son obligatorias',
'keychain.validation.labelAndUsernameRequired': 'La etiqueta y el nombre de usuario son obligatorios',
'keychain.error.generationUnavailable':
'La generación de claves no está disponible: asegúrate de que la app se esté ejecutando en Electron',
'keychain.error.generateKeyPairFailed': 'No se pudo generar el par de claves',
'keychain.error.generateKeyFailed': 'No se pudo generar la clave',
'keychain.error.keyGenerationTitle': 'Generación de claves',
'keychain.export.exportTo': 'Exportar a *',
'keychain.export.selectHost': 'Seleccionar host',
'keychain.export.location': 'Ubicación ~ $1 *',
'keychain.export.filename': 'Nombre de archivo ~ $2 *',
'keychain.export.note':
'La exportación de claves actualmente solo admite sistemas {unix}. Usa la sección {advanced} para personalizar el script de exportación.',
'keychain.export.script': 'Script *',
'keychain.export.scriptPlaceholder': 'Script de exportación...',
'keychain.export.missingCredentials':
'El host no tiene contraseña ni clave guardadas. Primero agrega credenciales de contraseña al host.',
'keychain.export.successTitle': 'Exportación exitosa',
'keychain.export.successMessage': 'Clave pública exportada y adjuntada a {host}',
'keychain.export.failedTitle': 'Falló la exportación',
'keychain.export.failedMessage': 'No se pudo exportar la clave: {error}',
'keychain.export.failedPrefix': 'Falló la exportación: {error}',
'keychain.export.exitCode': 'El comando terminó con el código {code}',
'keychain.export.exporting': 'Exportando...',
'keychain.export.exportAndAttach': 'Exportar y adjuntar',
'keychain.export.title': 'Exportación de clave',
'keychain.export.exportToRequired': 'Exportar a *',
'keychain.export.selectHostPlaceholder': 'Selecciona un host...',
'keychain.export.locationLabel': 'Ubicación ~ $1 *',
'keychain.export.filenameLabel': 'Nombre de archivo ~ $2 *',
'keychain.export.advanced': 'Avanzado',
'keychain.export.note.supportsOnly': 'La exportación de claves actualmente solo admite',
'keychain.export.note.systems': 'sistemas.',
'keychain.export.note.use': 'Usa la',
'keychain.export.note.customize': 'sección para personalizar el script de exportación.',
'keychain.export.scriptRequired': 'Script *',
'keychain.export.exportToHost': 'Exportar al host',
'keychain.export.failedGeneric': 'Falló la exportación: {message}',
'keychain.field.label': 'Etiqueta',
'keychain.field.labelRequired': 'Etiqueta *',
'keychain.field.labelPlaceholder': 'Etiqueta de la clave',
'keychain.field.privateKeyRequired': 'Clave privada *',
'keychain.field.publicKey': 'Clave pública',
'keychain.field.certificatePlaceholder': 'Contenido del certificado (opcional)',
'keychain.generate.keyType': 'Tipo de clave',
'keychain.generate.keySize': 'Tamaño de clave',
'keychain.generate.labelPlaceholder': 'Etiqueta de la clave',
'keychain.generate.passphrasePlaceholder': 'Frase de contraseña (opcional)',
'keychain.generate.savePassphrase': 'Guardar frase de contraseña',
'keychain.generate.generate': 'Generar',
'keychain.generate.generateSave': 'Generar y guardar',
'keychain.import.dropHint': 'Suelta un archivo de clave aquí',
'keychain.import.importFromFile': 'Importar desde archivo',
'keychain.import.saveKey': 'Guardar clave',
'keychain.import.importedKeyLabel': 'Clave importada',
'keychain.identity.usernameRequired': 'Nombre de usuario *',
'keychain.identity.method.passwordOnly': 'Contraseña',
'keychain.identity.summary.password': 'Contraseña de autenticación',
'keychain.identity.summary.key': 'Clave de autenticación',
'keychain.identity.summary.certificate': 'Certificado de autenticación',
'keychain.identity.summary.passwordAndKey': 'Contraseña y clave de autenticación',
'keychain.identity.summary.passwordAndCertificate': 'Contraseña y certificado de autenticación',
'keychain.identity.summary.none': 'Sin credenciales',
'keychain.identity.selectCredential': 'Seleccionar {kind}',
'keychain.identity.save': 'Guardar',
'keychain.identity.update': 'Actualizar',
'keychain.keyDialog.newTitle': 'Nueva clave',
'keychain.keyDialog.newDesc': 'Agregar una nueva clave SSH',
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
'tabs.closeLogViewAria': 'Cerrar vista de registro',
'tabs.closePluginViewAria': 'Cerrar {title}',
'tabs.logPrefix': 'Registro:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copiar pestaña',
'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.closeToRight': 'Cerrar pestañas a la derecha',
'tabs.closeAll': 'Cerrar todas',
'keychain.edit.labelRequired': 'Etiqueta *',
'keychain.edit.keyLabelPlaceholder': 'Etiqueta de la clave',
'keychain.edit.privateKeyRequired': 'Clave privada *',
'keychain.edit.publicKey': 'Clave pública',
'keychain.edit.certificate': 'Certificado',
'keychain.edit.certificatePlaceholder': 'Contenido del certificado (opcional)',
'keychain.edit.filePath': 'Ruta del archivo',
'keychain.edit.keyExport': 'Exportación de clave',
'keychain.edit.exportToHost': 'Exportar al host',
// Snippets
'snippets.searchPlaceholder': 'Buscar scripts...',
'snippets.action.newSnippet': 'Nuevo snippet',
'snippets.action.newPackage': 'Nuevo paquete de scripts',
'snippets.action.import': 'Importar',
'snippets.action.selectSnippets': 'Seleccionar snippets',
'snippets.panel.newTitle': 'Nuevo snippet',
'snippets.panel.editTitle': 'Editar snippet',
'snippets.panel.newAutomationTitle': 'Nuevo script de automatización',
'snippets.panel.editAutomationTitle': 'Editar script de automatización',
'snippets.panel.resizeWidth': 'Cambiar el ancho del panel',
'snippets.field.description': 'Descripción de la acción',
'snippets.field.descriptionPlaceholder': 'Ejemplo: verificar la carga de la red',
'snippets.field.package': 'Agregar un paquete de scripts',
'snippets.field.packagePlaceholder': 'Selecciona o crea un paquete de scripts',
'snippets.field.createPackage': 'Crear paquete de scripts',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Abrir en diálogo',
'snippets.scriptEditor.resize': 'Cambiar la altura del editor',
'snippets.scriptEditor.modalTitle': 'Editar script',
'snippets.targets.title': 'Destinos',
'snippets.targets.add': 'Agregar destinos',
'snippets.targets.selectHosts': 'Hosts',
'snippets.targets.selectGroups': 'Grupos',
'snippets.targets.noGroups': 'No se encontraron grupos',
'snippets.targets.allHosts': 'Aplicar a todos los hosts',
'snippets.targets.allHostsShort': 'Todos los hosts',
'snippets.targets.allHostsActive': 'Se aplica a cada host conectable.',
'snippets.history.title': 'Historial de Shell',
'snippets.history.subtitle': '{count} comandos',
'snippets.history.emptyTitle': 'Aún no hay historial de Shell',
'snippets.history.emptyDesc': 'Los comandos que ejecutes aparecerán aquí',
'snippets.history.loadMore': 'Cargar más',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Establece una etiqueta para este snippet',
'snippets.history.saveAsSnippet': 'Guardar como snippet',
'snippets.history.time.justNow': 'justo ahora',
'snippets.history.time.minutesAgo': 'hace {count}m',
'snippets.history.time.hoursAgo': 'hace {count}h',
'snippets.history.time.daysAgo': 'hace {count}d',
'snippets.breadcrumb.allPackages': 'Todos los paquetes de scripts',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Crea scripts',
'snippets.empty.desc': 'Guarda comandos comunes como snippets de código, o escribe scripts de automatización para operaciones repetibles.',
'snippets.search.noResults.title': 'Sin coincidencias',
'snippets.search.noResults.desc': 'Ningún script o paquete de scripts coincide con "{query}". Prueba con otro término de búsqueda o limpia la búsqueda para explorar.',
'snippets.section.packages': 'Paquetes de scripts',
'snippets.section.snippets': 'Scripts',
'snippets.kind.codeSnippet': 'Snippet de código',
'snippets.kind.automationScript': 'Script de automatización',
'snippets.package.count': '{count} script(s)',
'snippets.commandFallback': 'Comando',
'snippets.view.grid': 'Cuadrícula',
'snippets.view.list': 'Lista',
'snippets.selection.selected': '{count} seleccionados',
'snippets.selection.selectVisible': 'Seleccionar visibles',
'snippets.selection.deselectAll': 'Deseleccionar todo',
'snippets.selection.exportSelected': 'Exportar seleccionados ({count})',
'snippets.selection.deleteSelected': 'Eliminar ({count})',
'snippets.selection.deleteConfirmTitle': '¿Eliminar los elementos seleccionados ({count})?',
'snippets.selection.deleteConfirmDesc': 'Los elementos seleccionados se eliminarán de forma permanente. Esta acción no se puede deshacer.',
'snippets.selection.deleteSuccess': 'Elementos seleccionados eliminados: {count}.',
'snippets.export.snippet': 'Exportar snippet',
'snippets.export.package': 'Exportar paquete de scripts',
'snippets.export.toast.empty': 'No hay snippets para exportar.',
'snippets.export.toast.successTitle': 'Exportación lista',
'snippets.export.toast.success': 'Se exportaron {count} snippet(s).',
'snippets.import.toast.empty': 'No se encontraron snippets importables.',
'snippets.import.toast.failedTitle': 'Falló la importación',
'snippets.import.toast.invalidDesc': 'Este no es un archivo de snippets válido de Netcatty.',
'snippets.import.toast.successTitle': 'Importación completada',
'snippets.import.toast.summary': 'Importados {imported}, sobrescritos {overwritten}, omitidos {skipped}.',
'snippets.import.modal.title': 'Importar snippets',
'snippets.import.modal.desc': 'Elige uno o más archivos JSON de snippets de Netcatty. Netcatty los mostrará en vista previa antes de importar cualquier cosa.',
'snippets.import.modal.exampleTitle': 'Ejemplo de JSON',
'snippets.import.modal.noFile': 'Aún no se seleccionó ningún archivo. Usa el formato de ejemplo de abajo, o un arreglo JSON simple de snippets, y luego elige uno o más archivos.',
'snippets.import.modal.chooseFile': 'Elegir archivo',
'snippets.import.modal.downloadExamples': 'Descargar ejemplos',
'snippets.import.modal.multipleFiles': '{count} archivos seleccionados',
'snippets.import.modal.parsedSummary': '{files} archivo(s), {total} script(s), {packages} paquete(s) de scripts, {conflicts} comando(s) duplicado(s). Las vinculaciones de host se ignoran.',
'snippets.import.modal.confirm': 'Confirmar importación',
'snippets.import.conflict.title': '¿Importar snippets?',
'snippets.import.conflict.desc': '{file} contiene {total} snippet(s). Ya existen {conflicts} comando(s) duplicado(s).',
'snippets.import.conflict.hostBindingsNote': 'Las vinculaciones de host no se importan ni se exportan con los snippets.',
'snippets.import.conflict.skip': 'Omitir duplicados',
'snippets.import.conflict.overwrite': 'Sobrescribir duplicados',
'snippets.packageDialog.title': 'Nuevo paquete de scripts',
'snippets.packageDialog.parent': 'Padre: {parent}',
'snippets.packageDialog.root': 'Raíz',
'snippets.packageDialog.placeholder': 'p. ej., ops/maintenance',
'snippets.packageDialog.hint': 'Usa "/" para crear paquetes de scripts anidados.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Renombrar paquete de scripts',
'snippets.renameDialog.currentPath': 'Ruta actual: {path}',
'snippets.renameDialog.placeholder': 'Ingresa el nuevo nombre',
'snippets.renameDialog.error.empty': 'El nombre del paquete de scripts no puede estar vacío',
'snippets.renameDialog.error.duplicate': 'Ya existe un paquete de scripts con este nombre',
'snippets.renameDialog.error.invalidChars': 'El nombre del paquete de scripts solo puede contener letras, números, guiones y guiones bajos',
'snippets.field.noAutoRun': 'Solo pegar (no auto-ejecutar)',
'snippets.field.multiLineRunMode': 'Ejecución multilínea',
'snippets.field.multiLineRunMode.paste': 'Enviar todo de una vez',
'snippets.field.multiLineRunMode.lineDelay': 'Enviar línea por línea',
'snippets.field.multiLineRunModeHint': 'Usa línea por línea para inicios de sesión que piden credenciales (prompt) o macros de dispositivos.',
// Snippet Shortkey
'snippets.field.shortkey': 'Atajo de teclado',
'snippets.shortkey.placeholder': 'Haz clic para configurar el atajo',
'snippets.shortkey.recording': 'Presiona una combinación de teclas...',
'snippets.shortkey.hint': 'Presiona este atajo en la terminal para enviar el comando rápidamente.',
'snippets.shortkey.clear': 'Borrar atajo',
'snippets.shortkey.error.systemConflict': 'Este atajo entra en conflicto con {name}',
'snippets.shortkey.error.snippetConflict': 'Este atajo ya lo usa el snippet: {name}',
'snippets.variables.dialogTitle': 'Variables del snippet',
'snippets.variables.dialogDesc': 'Completa los valores de "{label}" antes de ejecutar.',
'snippets.variables.hint': 'Los valores se insertan tal cual en el script (sin escapado de shell).',
'snippets.variables.preview': 'Vista previa',
'snippets.variables.placeholder': 'Ingresa un valor',
'snippets.variables.placeholderDefault': 'Predeterminado: {value}',
'snippets.variables.required': 'Esta variable es obligatoria',
'snippets.variables.run': 'Ejecutar',
'snippets.field.variablesHelp': 'Usa {{name}} o {{name:default}} para los marcadores de posición en el script.',
'snippets.field.variablesDetected': 'Variables',
'snippets.field.variableDefault': 'predeterminado {value}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Conectar a puerto serial',
'serial.modal.desc': 'Configura los ajustes de conexión del puerto serial',
'serial.field.port': 'Puerto serial',
'serial.field.selectPort': 'Selecciona un puerto...',
'serial.field.baudRate': 'Velocidad de baudios',
'serial.field.dataBits': 'Bits de datos',
'serial.field.stopBits': 'Bits de parada',
'serial.field.stopBits15Warning': 'Los 1.5 bits de parada pueden no ser compatibles con todos los dispositivos Windows',
'serial.field.parity': 'Paridad',
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
'serial.type.custom': 'Personalizado',
'serial.parity.none': 'Ninguna',
'serial.parity.even': 'Par',
'serial.parity.odd': 'Impar',
'serial.parity.mark': 'Marca',
'serial.parity.space': 'Espacio',
'serial.flowControl.none': 'Ninguno',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Forzar eco local',
'serial.field.localEchoDesc': 'Hacer eco de los caracteres escritos localmente (para dispositivos sin eco remoto)',
'serial.field.lineMode': 'Modo de línea',
'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.backspace.default': 'Predeterminado (DEL, 0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
'serial.field.charset': 'Codificación de caracteres',
'serial.connectionError': 'No se pudo conectar al puerto serial',
'serial.field.baudRatePlaceholder': 'Selecciona o ingresa la velocidad de baudios...',
'serial.field.baudRateEmpty': 'Ingresa una velocidad de baudios personalizada',
'serial.field.customBaudRate': 'Usando velocidad de baudios personalizada',
'serial.field.saveConfig': 'Guardar configuración',
'serial.field.saveConfigDesc': 'Guarda esta configuración serial en hosts para acceso rápido',
'serial.field.configLabel': 'Nombre de configuración',
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
'keyboard.interactive.response': 'Respuesta',
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
'keyboard.interactive.submit': 'Enviar',
'keyboard.interactive.verifying': 'Verificando...',
'keyboard.interactive.savePassword': 'Guardar contraseña',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Frase de contraseña de la clave SSH',
'passphrase.desc': 'Ingresa la frase de contraseña de {keyName}',
'passphrase.descWithHost': 'Ingresa la frase de contraseña de {keyName} para conectarte a {hostname}',
'passphrase.label': 'Frase de contraseña',
'passphrase.keyPath': 'Clave',
'passphrase.unlock': 'Desbloquear',
'passphrase.unlocking': 'Desbloqueando...',
'passphrase.skip': 'Omitir',
'passphrase.remember': 'Recordar esta frase de contraseña',
// Text Editor
'sftp.editor.wordWrap': 'Ajuste de línea',
'sftp.editor.maximize': 'Maximizar',
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
'sftp.editor.discardChanges': 'Descartar',
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
};
+995
View File
@@ -0,0 +1,995 @@
import type { Messages } from '../types';
export const esVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Buscar notas, hosts, etiquetas...',
'notes.action.newNote': 'Nueva nota',
'notes.action.newGroup': 'Nueva carpeta',
'notes.action.importMarkdown': 'Importar Markdown',
'notes.action.exportNote': 'Exportar nota',
'notes.action.exportGroup': 'Exportar carpeta',
'notes.action.exportAll': 'Exportar notas',
'notes.import.toast.success': 'Se importaron {count} nota(s)',
'notes.import.toast.noNotes': 'No se importaron archivos Markdown.',
'notes.import.toast.failed': 'No se pudieron importar los archivos Markdown.',
'notes.import.toast.inProgress': 'Ya hay una importación de Markdown en curso.',
'notes.import.toast.skipped': 'Se omitieron {count} archivo(s) no compatible(s).',
'notes.export.toast.success': 'Se exportaron {count} nota(s)',
'notes.export.toast.empty': 'No hay notas para exportar.',
'notes.export.toast.failed': 'No se pudieron exportar las notas.',
'notes.codeBlock.copied': 'Copiado',
'notes.codeBlock.copyFailed': 'No se pudo copiar el bloque de código.',
'notes.group.prompt': 'Ruta de la carpeta',
'notes.group.all': 'Cuaderno',
'notes.group.ungrouped': 'Sin grupo',
'notes.section.notebooks': 'Cuaderno',
'notes.section.notes': 'Notas',
'notes.mode.edit': 'Editar',
'notes.mode.split': 'Dividir',
'notes.mode.preview': 'Vista previa',
'notes.title.placeholder': 'Título de la nota',
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
'notes.empty.title': 'Aún no hay notas',
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
'notes.empty.group': 'No hay notas en este cuaderno.',
'notes.search.noResults': 'No se encontraron notas.',
// Vault import
'vault.import.title': 'Agrega datos a tu Vault',
'vault.import.desc':
'Transfiere tus conexiones desde clientes populares. Selecciona un formato de archivo para comenzar la migración.',
'vault.import.chooseFormat': 'Selecciona un formato de archivo',
'vault.import.destination.title': '¿Dónde deberían ir los hosts importados?',
'vault.import.destination.preserve': 'Conservar los grupos originales',
'vault.import.destination.existing': 'Elegir un grupo',
'vault.import.destination.new': 'Crear un grupo',
'vault.import.destination.noGroups': 'No hay grupos disponibles',
'vault.import.destination.newPlaceholder': 'Nombre del grupo nuevo',
'vault.import.destination.settings': 'Ubicación de importación',
'vault.import.destination.settingsHint': 'Elige dónde se colocarán los hosts nuevos',
'vault.import.destination.done': 'Listo',
'vault.import.securecrt.directoryHint': 'Selecciona la carpeta de sesiones de SecureCRT.',
'vault.import.securecrt.chooseSource': 'Elige una fuente de importación de SecureCRT.',
'vault.import.securecrt.sourceQuestion': '¿Qué te gustaría importar?',
'vault.import.securecrt.promptTitle': 'Importar desde SecureCRT',
'vault.import.securecrt.promptDesc':
'Elige la carpeta de sesiones de SecureCRT para importar todas las sesiones conservando la estructura de carpetas, o selecciona un solo archivo de sesión .ini.',
'vault.import.securecrt.folder': 'Carpeta de sesiones',
'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.csv.tip': 'Importación masiva: usa la plantilla CSV.',
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
'vault.import.toast.start': 'Importando desde {format}...',
'vault.import.toast.completedTitle': 'Importación completada',
'vault.import.toast.failedTitle': 'Importación fallida',
'vault.import.toast.noEntries': 'No se encontraron entradas importables en {format}.',
'vault.import.toast.noNewHosts': 'No se importaron hosts nuevos desde {format}.',
'vault.import.toast.summary':
'Se importaron {count} hosts (omitidos {skipped}, duplicados {duplicates}).',
'vault.import.toast.firstIssue': 'Primer problema: {issue}',
'vault.import.progress.title': 'Importando hosts',
'vault.import.progress.reading': 'Leyendo archivo',
'vault.import.progress.parsing': 'Analizando hosts',
'vault.import.progress.preparing': 'Preparando cambios',
'vault.import.progress.saving': 'Guardando hosts',
'vault.import.progress.complete': 'Importación completa',
'vault.import.progress.failed': 'Importación fallida',
'vault.import.progress.persistFailed': 'No se pudieron guardar los hosts importados. Libera algo de espacio de almacenamiento local e inténtalo de nuevo.',
'vault.import.progress.rollbackFailed': 'La importación no se pudo revertir de forma segura. Es posible que queden algunos hosts importados; revisa el Vault antes de reintentar.',
'vault.import.progress.summary':
'Se importaron {count} hosts; omitidos {skipped}; duplicados {duplicates}.',
'vault.import.progress.keepOpen': 'Mantén Netcatty abierto mientras la importación se ejecuta en segundo plano.',
'vault.import.progress.fileSummary': '{name} · {count} archivos',
'vault.import.progress.fileCount': '{completed} de {total} archivos',
'vault.import.mobaxterm.chooseEncoding': 'Elige cómo leer tu archivo de MobaXterm.',
'vault.import.mobaxterm.encodingQuestion': '¿Qué codificación de texto usa este archivo?',
'vault.import.mobaxterm.auto': 'Detección automática',
'vault.import.mobaxterm.autoDesc': 'Recomendado. Usa UTF-8 cuando sea válido; de lo contrario, la codificación de Windows en chino.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
'vault.import.mobaxterm.gb18030': 'Windows en chino',
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
'vault.import.sshConfig.importOnly': 'Solo importar',
'vault.import.sshConfig.importOnlyDesc': 'Importación única. Los cambios no se sincronizarán de vuelta al archivo.',
'vault.import.sshConfig.managed': 'Sincronización administrada',
'vault.import.sshConfig.managedDesc': 'Mantén la sincronización. Los cambios se guardarán de vuelta en el archivo.',
'vault.import.sshConfig.managedDestinationHint': 'La sincronización administrada siempre usa su propio grupo administrado; el destino anterior aplica solo a la importación única.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Se importaron {count} hosts. El archivo ahora está administrado.',
'vault.import.sshConfig.alreadyManaged': 'Este archivo ya está siendo administrado.',
'vault.import.sshConfig.alreadyManagedDesc': 'Este archivo ya está administrado en el grupo "{group}". Elimina primero la fuente administrada existente si quieres volver a importar.',
'vault.import.sshConfig.noFilePath': 'No se puede administrar este archivo.',
'vault.import.sshConfig.noFilePathDesc': 'No se pudo determinar la ruta del archivo. La sincronización administrada requiere acceso al sistema de archivos.',
// Known Hosts
'knownHosts.search.placeholder': 'Buscar hosts conocidos...',
'knownHosts.action.scanSystem': 'Escanear sistema',
'knownHosts.action.importFile': 'Importar archivo',
'knownHosts.action.browseFile': 'Explorar archivo',
'knownHosts.empty.title': 'No hay hosts conocidos',
'knownHosts.empty.desc':
"Los hosts conocidos son servidores SSH a los que te conectaste antes. Importa desde el archivo known_hosts de tu sistema para comenzar.",
'knownHosts.results.showingLimited':
'Mostrando {shown} de {total} hosts. Usa la búsqueda para encontrar hosts específicos.',
'knownHosts.toast.scanUnavailable': 'El escaneo del sistema no está disponible en esta plataforma.',
'knownHosts.toast.scanNoFile': 'No se encontró un archivo known_hosts del sistema.',
'knownHosts.toast.scanNoEntries': 'No se encontraron entradas utilizables en known_hosts.',
'knownHosts.toast.scanImported': 'Se importaron {count} hosts nuevos.',
'knownHosts.toast.scanNoNew': 'No se encontraron hosts nuevos.',
'knownHosts.toast.scanFailed': 'No se pudo escanear el known_hosts del sistema.',
// Port Forwarding
'pf.empty.title': 'Configura el reenvío de puertos',
'pf.empty.desc': 'Guarda el reenvío de puertos para acceder a bases de datos, aplicaciones web y otros servicios.',
'pf.title': 'Reenvío de puertos',
'pf.rulesCount': '{count} reglas',
'pf.wizard.editTitle': 'Editar reenvío de puertos',
'pf.wizard.newTitle': 'Nuevo reenvío de puertos',
'pf.wizard.saveChanges': 'Guardar cambios',
'pf.wizard.done': 'Listo',
'pf.wizard.continue': 'Continuar',
'pf.wizard.cancel': 'Cancelar',
'pf.wizard.skipWizard': 'Omitir asistente',
'pf.error.hostNotFound': 'No se encontró el host',
'pf.toast.titleWithLabel': 'Reenvío de puertos: {label}',
'pf.type.local': 'Local',
'pf.type.remote': 'Remoto',
'pf.type.dynamic': 'Dinámico',
'pf.type.menu.local': 'Reenvío local',
'pf.type.menu.remote': 'Reenvío remoto',
'pf.type.menu.dynamic': 'Reenvío dinámico',
'pf.type.local.desc': 'El reenvío local te permite acceder al puerto de escucha de un servidor remoto como si fuera local.',
'pf.type.remote.desc': 'El reenvío remoto abre un puerto en la máquina remota y reenvía las conexiones al host local (actual).',
'pf.type.dynamic.desc': 'El reenvío dinámico de puertos convierte a Netcatty en un servidor proxy SOCKS.',
'pf.wizard.type.title': 'Selecciona el tipo de reenvío de puertos:',
'pf.wizard.localConfig.title': 'Configura el puerto local y la dirección de enlace:',
'pf.wizard.localConfig.desc': 'Este puerto estará abierto en el dispositivo local (actual) y recibirá el tráfico.',
'pf.wizard.localConfig.localPort': 'Número de puerto local *',
'pf.wizard.bindAddress': 'Dirección de enlace',
'pf.wizard.remoteHost.title': 'Selecciona el host remoto:',
'pf.wizard.remoteHost.desc': 'Selecciona un host donde estará abierto el puerto. El tráfico de este puerto se reenviará al host de destino.',
'pf.wizard.remoteConfig.title': 'Configura el puerto y la dirección de enlace:',
'pf.wizard.remoteConfig.desc': 'El tráfico se reenviará desde el puerto especificado y la dirección de interfaz del host seleccionado.',
'pf.wizard.remoteConfig.remotePort': 'Número de puerto remoto *',
'pf.wizard.destination.title': 'Selecciona el host de destino:',
'pf.wizard.destination.desc.local': 'Ingresa el destino remoto al que quieres acceder a través del túnel.',
'pf.wizard.destination.desc.remote': 'La dirección y el puerto de destino hacia donde se reenviará el tráfico.',
'pf.wizard.destination.address': 'Dirección de destino *',
'pf.wizard.destination.addressPlaceholder': 'p. ej. 127.0.0.1 o 192.168.1.100',
'pf.wizard.destination.port': 'Número de puerto de destino *',
'pf.wizard.sshServer.title': 'Selecciona el servidor SSH:',
'pf.wizard.sshServer.desc.dynamic': 'Selecciona el servidor SSH que actuará como tu proxy SOCKS.',
'pf.wizard.sshServer.desc.default': 'Selecciona el servidor SSH que tunelizará tu tráfico hacia el destino.',
'pf.wizard.label.title': 'Selecciona la etiqueta:',
'pf.wizard.label.placeholder.dynamic': 'p. ej. Proxy SOCKS',
'pf.wizard.label.placeholder.default': 'p. ej. MySQL Producción',
'pf.wizard.label.placeholder.remoteRule': 'p. ej. Regla remota',
'pf.wizard.placeholders.portExample': 'p. ej. {port}',
'pf.action.newForwarding': 'Nuevo reenvío',
'pf.form.labelPlaceholder': 'Etiqueta de la regla',
'pf.form.intermediateHost': 'Host intermedio *',
'pf.form.createRule': 'Crear regla',
'pf.form.openWizard': 'Abrir asistente',
'pf.form.openWizardTitle': 'Abrir el asistente de reenvío de puertos',
'pf.view.grid': 'Cuadrícula',
'pf.view.list': 'Lista',
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> Túnel SSH -> {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Remoto {bindAddress}:{localPort} -> Túnel SSH -> local {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS en {bindAddress}:{localPort}',
'pf.tooltip.relayHost': 'Host de relevo',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Dirección',
'pf.tooltip.noHost': 'No hay host de relevo configurado',
'pf.tooltip.localDesc': 'Reenvío de puerto local: accede a servicios remotos a través del túnel SSH',
'pf.tooltip.remoteDesc': 'Reenvío de puerto remoto: expone servicios locales al host remoto',
'pf.tooltip.dynamicDesc': 'Proxy SOCKS dinámico: enruta el tráfico a través del túnel SSH',
'pf.deleteActive.title': '¿Eliminar el reenvío de puertos activo?',
'pf.deleteActive.desc': 'Esta regla de reenvío de puertos "{label}" está activa actualmente. Eliminarla primero detendrá el túnel.',
'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',
// SFTP
'sftp.newFolder': 'Nueva carpeta',
'sftp.newFile': 'Nuevo archivo',
'sftp.filter': 'Filtrar',
'sftp.filter.placeholder': 'Filtrar por nombre de archivo...',
'sftp.bookmark.add': 'Marcar esta ruta',
'sftp.bookmark.remove': 'Quitar marcador',
'sftp.bookmark.list': 'Rutas marcadas',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
'sftp.bookmark.empty': 'Aún no hay marcadores',
'sftp.columns.name': 'Nombre',
'sftp.columns.modified': 'Modificado',
'sftp.columns.size': 'Tamaño',
'sftp.columns.kind': 'Tipo',
'sftp.columns.owner': 'Propietario',
'sftp.columns.configure': 'Elegir columnas visibles',
'sftp.columns.actions': 'Acciones',
'sftp.sort.directoriesFirst': 'Carpetas primero',
'sftp.emptyDirectory': 'Directorio vacío',
'sftp.nav.up': 'Subir un nivel',
'sftp.nav.home': 'Ir al inicio',
'sftp.nav.refresh': 'Actualizar',
'sftp.upload': 'Subir',
'sftp.uploadFiles': 'Subir archivos',
'sftp.uploadFolder': 'Subir carpeta',
'sftp.dragDropToUpload': 'Arrastra y suelta archivos aquí para subirlos',
'sftp.retry': 'Reintentar',
'sftp.context.open': 'Abrir',
'sftp.context.navigateTo': 'Navegar a',
'sftp.context.moveTo': 'Mover a...',
'sftp.context.moveToParent': 'Mover al directorio principal',
'sftp.moveTo.title': 'Mover a directorio',
'sftp.moveTo.placeholder': 'Ingresa la ruta del directorio de destino',
'sftp.moveTo.confirm': 'Mover',
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
'sftp.context.download': 'Descargar',
'sftp.context.extract': 'Extraer aquí',
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
'sftp.copyCurrentPath.error': 'No se pudo copiar la ruta actual',
'sftp.viewMode.label': 'Modo de vista',
'sftp.viewMode.list': 'Vista de lista',
'sftp.viewMode.tree': 'Vista de árbol',
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
'sftp.tree.loadError': 'No se pudo cargar el directorio',
'sftp.tree.loading': 'Cargando...',
'sftp.kind.folder': 'Carpeta',
'sftp.context.rename': 'Renombrar',
'sftp.context.permissions': 'Permisos',
'sftp.context.delete': 'Eliminar',
'sftp.context.refresh': 'Actualizar',
'sftp.context.uploadFiles': 'Subir archivo(s)...',
'sftp.context.uploadFilesHere': 'Subir archivo(s) aquí...',
'sftp.context.uploadFolder': 'Subir carpeta...',
'sftp.context.uploadFolderHere': 'Subir carpeta aquí...',
'sftp.context.downloadSelected': 'Descargar seleccionados ({count})',
'sftp.context.deleteSelected': 'Eliminar seleccionados ({count})',
'sftp.dropFilesHere': 'Suelta los archivos aquí',
'sftp.itemsCount': '{count} elementos',
'sftp.selectedCount': '{count} seleccionados',
'sftp.path.doubleClickToEdit': 'Haz doble clic para editar la ruta',
'sftp.showHiddenPaths': 'Rutas ocultas',
'sftp.task.waiting': 'Esperando...',
'sftp.transfer.preparing': 'preparando...',
'sftp.status.loading': 'Cargando...',
'sftp.status.uploading': 'Subiendo...',
'sftp.status.ready': 'Listo',
'sftp.transfers': 'Transferencias',
'sftp.transfers.active': '{count} activas',
'sftp.transfers.clearCompleted': 'Limpiar completadas',
'sftp.transfers.calculatingTotal': 'Calculando el tamaño total...',
'sftp.transfers.filesCount': '{count} archivos',
'sftp.transfers.filesProgress': '{current}/{total} archivos',
// Progressive folder walk: total is "found so far", not a fixed grand total.
'sftp.transfers.filesDiscoveredProgress': '{completed} completados · {discovered} encontrados',
'sftp.transfers.expandChildren': 'Mostrar archivos',
'sftp.transfers.collapseChildren': 'Ocultar archivos',
'sftp.transfers.expandChildList': 'Mostrar detalle',
'sftp.transfers.collapseChildList': 'Ocultar',
'sftp.transfers.retryAction': 'Reintentar',
'sftp.transfers.dismissAction': 'Descartar',
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
'sftp.transfers.pathConflict.paused': 'Otra transferencia de "{name}" está en pausa. Reanúdala o cancélala primero.',
'sftp.transfers.pathConflict.inProgress': 'Otra transferencia de "{name}" ya está en curso.',
'sftp.transferCenter.title': 'Transferencias de archivos',
'sftp.transferCenter.quitConfirm': '{count} transferencia(s) de archivos quedaron sin terminar. El progreso se guardará y deberá reanudarse manualmente la próxima vez. ¿Salir de todos modos?',
'sftp.transferCenter.phase.scanning': 'Escaneando',
'sftp.transferCenter.phase.compressing': 'Comprimiendo',
'sftp.transferCenter.phase.uploading': 'Subiendo',
'sftp.transferCenter.phase.transferring': 'Transfiriendo',
'sftp.transferCenter.phase.extracting': 'Extrayendo',
'sftp.transferCenter.phase.verifying': 'Verificando',
'sftp.transferCenter.bucket.active': 'En progreso',
'sftp.transferCenter.bucket.all': 'Todas',
'sftp.transferCenter.bucket.queued': 'En cola',
'sftp.transferCenter.bucket.paused': 'En pausa',
'sftp.transferCenter.bucket.failed': 'Fallidas',
'sftp.transferCenter.bucket.attention': 'Requieren atención',
'sftp.transferCenter.bucket.completed': 'Completadas',
'sftp.transferCenter.status.pending': 'Preparando',
'sftp.transferCenter.status.resuming': 'Reconectando y reanudando…',
'sftp.transferCenter.status.queued': 'En cola',
'sftp.transferCenter.status.transferring': 'Transfiriendo',
'sftp.transferCenter.status.pausing': 'Pausando',
'sftp.transferCenter.status.paused': 'En pausa',
'sftp.transferCenter.status.attention': 'Requiere atención',
'sftp.transferCenter.status.interrupted': 'Interrumpida: reanúdala manualmente',
'sftp.transferCenter.status.completed': 'Completada',
'sftp.transferCenter.status.failed': 'Fallida',
'sftp.transferCenter.status.cancelled': 'Cancelada',
'sftp.transferCenter.background': 'Segundo plano',
'sftp.transferCenter.pause': 'Pausar',
'sftp.transferCenter.resume': 'Reanudar',
'sftp.transferCenter.prioritize': 'Transferir a continuación',
'sftp.transferCenter.pauseAll': 'Pausar todas',
'sftp.transferCenter.resumeAll': 'Reanudar 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',
'sftp.transferCenter.clear': 'Limpiar registros',
'sftp.transferCenter.applyAll': 'Aplicar a todas',
'sftp.transfers.resizeNameColumn': 'Redimensionar la columna de nombre de archivo',
'sftp.transfers.dragToResize': 'Arrastra para redimensionar',
'sftp.goUp': 'Subir un nivel',
'sftp.goToTerminalCwd': 'Ir al directorio de la terminal',
'sftp.locatePathInTerminal': 'Abrir ruta en la terminal',
'sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
'sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal',
'sftp.followTerminalCwd.disable': 'Deshabilitar el seguimiento del directorio de la terminal',
'sftp.encoding.label': 'Codificación de nombres de archivo',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Ir al inicio',
'sftp.folderName': 'Nombre de la carpeta',
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
'sftp.fileName': 'Nombre del archivo',
'sftp.fileName.placeholder': 'Ingresa el nombre del archivo',
'sftp.prompt.newFolderName': '¿Nombre de la carpeta nueva?',
'sftp.rename.title': 'Renombrar',
'sftp.rename.newName': 'Nombre nuevo',
'sftp.rename.placeholder': 'Ingresa el nombre nuevo',
'sftp.confirm.deleteOne': '¿Eliminar "{name}"?',
'sftp.deleteConfirm.single': '¿Eliminar "{name}"?',
'sftp.deleteConfirm.title': '¿Eliminar {count} elemento(s)?',
'sftp.deleteConfirm.desc': 'Esta acción no se puede deshacer. Se eliminará lo siguiente:',
'sftp.deleteConfirm.descSingle': 'Esta acción no se puede deshacer.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Ruta',
'sftp.error.loadFailed': 'No se pudo cargar el directorio',
'sftp.error.downloadFailed': 'No se pudo descargar',
'sftp.error.uploadFailed': 'No se pudo subir',
'sftp.error.deleteFailed': 'No se pudo eliminar',
'sftp.error.createFolderFailed': 'No se pudo crear la carpeta',
'sftp.error.createFileFailed': 'No se pudo crear el archivo',
'sftp.error.invalidFileName': 'El nombre de archivo contiene caracteres no válidos: {chars}',
'sftp.error.reservedName': 'Este nombre de archivo está reservado por el sistema',
'sftp.overwrite.title': 'El archivo ya existe',
'sftp.overwrite.desc': 'Ya existe un archivo llamado "{name}". ¿Quieres reemplazarlo?',
'sftp.overwrite.confirm': 'Reemplazar',
'sftp.error.renameFailed': 'No se pudo renombrar',
'sftp.picker.title': 'Seleccionar host',
'sftp.picker.desc': 'Elige un host para el panel {side}',
'sftp.picker.searchPlaceholder': 'Buscar hosts...',
'sftp.picker.local.title': 'Sistema de archivos local',
'sftp.picker.local.desc': 'Explorar archivos locales',
'sftp.picker.local.badge': 'Local',
'sftp.picker.connected.section': 'Conectados',
'sftp.picker.connected.badge': 'En vivo',
'sftp.picker.connected.connecting': 'Conectando',
'sftp.picker.noMatch': 'No hay hosts que coincidan',
'sftp.permissions.title': 'Editar permisos',
'sftp.permissions.owner': 'Propietario',
'sftp.permissions.group': 'Grupo',
'sftp.permissions.others': 'Otros',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Simbólico',
'sftp.permissions.success': 'Permisos actualizados correctamente',
'sftp.permissions.failed': 'No se pudieron actualizar los permisos',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remoto',
'sftp.pane.selectHost': 'Seleccionar host',
'sftp.pane.selectHostToStart': 'Selecciona un host para comenzar',
'sftp.pane.chooseFilesystem': 'Elige un sistema de archivos local o remoto para explorar',
'sftp.tabs.addTab': 'Agregar pestaña nueva',
'sftp.tabs.closeTab': 'Cerrar pestaña',
'sftp.tabs.newTab': 'Pestaña nueva',
'sftp.tabs.copyDefaultPath': 'Copiar pestaña (ruta predeterminada)',
'sftp.tabs.copyCurrentPath': 'Copiar e ir a la ruta actual',
'sftp.conflict.title': 'Conflicto de archivos',
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
'sftp.conflict.folderTitle': 'Conflicto de carpetas',
'sftp.conflict.folderDesc': 'Ya existe una carpeta con el mismo nombre en el destino',
'sftp.conflict.folderFileDesc': 'Ya existe un archivo con el mismo nombre en el destino. Una carpeta no se puede combinar con un archivo.',
'sftp.conflict.folderSymlinkDesc': 'Ya existe un enlace simbólico con el mismo nombre en el destino. Una carpeta no se puede combinar con un enlace simbólico.',
'sftp.conflict.folderUnknownDesc': 'Ya existe un elemento con el mismo nombre en el destino, pero no se pudo confirmar su tipo. Reemplazar no está disponible para proteger los datos del destino.',
'sftp.conflict.folderMergeHint': 'Combinar conserva los archivos y subcarpetas que solo existen en el destino.',
'sftp.conflict.folderReplaceWarning': 'Reemplazar elimina todos los archivos y subcarpetas del destino que no estén en el origen. Los datos eliminados no se pueden recuperar.',
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
'sftp.conflict.existingFile': 'Archivo existente',
'sftp.conflict.newFile': 'Archivo nuevo',
'sftp.conflict.size': 'Tamaño:',
'sftp.conflict.modified': 'Modificado:',
'sftp.conflict.applyToAll': 'Aplicar esta acción a los {count} conflictos restantes',
'sftp.conflict.action.stop': 'Detener',
'sftp.conflict.action.skip': 'Omitir',
'sftp.conflict.action.keepBoth': 'Conservar ambos',
'sftp.conflict.action.duplicate': 'Duplicar',
'sftp.conflict.action.merge': 'Combinar',
'sftp.conflict.action.replace': 'Reemplazar',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Comprimiendo',
'sftp.upload.phase.uploading': 'Subiendo',
'sftp.upload.phase.extracting': 'Extrayendo',
'sftp.upload.phase.compressed': 'Comprimido',
// SFTP File Opener
'sftp.context.copyPath': 'Copiar ruta del archivo',
'sftp.context.openWithDefault': 'Abrir con la aplicación predeterminada del sistema',
'sftp.context.openWith': 'Abrir con...',
'sftp.context.edit': 'Editar',
'sftp.context.preview': 'Vista previa',
'sftp.opener.title': 'Abrir con',
'sftp.opener.desc': 'Elige una aplicación para abrir este archivo',
'sftp.opener.builtInEditor': 'Editor integrado',
'sftp.opener.editDescription': 'Editar archivos de texto',
'sftp.opener.builtInImageViewer': 'Visor de imágenes integrado',
'sftp.opener.previewDescription': 'Previsualizar imágenes',
'sftp.opener.systemApp': 'Elegir aplicación...',
'sftp.opener.systemAppDescription': 'Selecciona una aplicación de tu computadora',
'sftp.opener.onlySystemApp': 'Este archivo solo se puede abrir con una aplicación externa',
'sftp.opener.noAppsAvailable': 'No hay aplicaciones disponibles',
'sftp.opener.noExtension': 'archivos sin extensión',
'sftp.opener.setDefault': 'Usar siempre esto para archivos {ext}',
'sftp.opener.confirmTitle': '¿Establecer como predeterminado?',
'sftp.opener.confirmDescription': '¿Quieres usar siempre {app} para archivos {ext}?',
'sftp.opener.yesRemember': 'Sí, recordar esta elección',
'sftp.opener.justOnce': 'Solo esta vez',
'sftp.opener.confirm.title': 'Establecer aplicación predeterminada',
'sftp.opener.confirm.desc': '¿Quieres abrir siempre los archivos .{ext} con esta aplicación?',
'sftp.editor.title': 'Editor de texto',
'sftp.editor.save': 'Guardar en remoto',
'sftp.editor.saving': 'Guardando...',
'sftp.editor.saved': 'Guardado correctamente',
'sftp.editor.saveFailed': 'No se pudo guardar el archivo',
'sftp.editor.unsavedChanges': 'Tienes cambios sin guardar. ¿Cerrar de todos modos?',
'sftp.editor.syntaxHighlight': 'Resaltado de sintaxis',
'sftp.preview.title': 'Vista previa de imagen',
'sftp.preview.zoomIn': 'Acercar',
'sftp.preview.zoomOut': 'Alejar',
'sftp.preview.resetZoom': 'Restablecer zoom',
'sftp.preview.fitToWindow': 'Ajustar a la ventana',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Concurrencia de transferencias',
'settings.sftp.transferConcurrency.desc': 'Cantidad máxima de archivos transferidos en paralelo para el mismo endpoint, incluidos los contenidos de carpetas y los archivos seleccionados por separado. Los valores más altos pueden mejorar la velocidad, pero pueden abrumar a algunos servidores.',
'settings.sftp.transferPoolIdleTtl': 'Mantener activa la conexión de transferencia',
'settings.sftp.transferPoolIdleTtl.desc': 'Cuánto tiempo las conexiones SFTP dedicadas de transferencia permanecen activas después de estar inactivas. Los valores más largos reducen la demora de arranque en frío antes de la siguiente subida o descarga. Las transferencias ocupadas nunca se cierran.',
'settings.sftp.transferPoolIdleTtl.1m': '1 minuto',
'settings.sftp.transferPoolIdleTtl.5m': '5 minutos',
'settings.sftp.transferPoolIdleTtl.15m': '15 minutos',
'settings.sftp.transferPoolIdleTtl.30m': '30 minutos',
'settings.sftp.transferPoolIdleTtl.never': 'Hasta que se cierre la aplicación',
'settings.ssh.transportIdleTtl': 'Mantener activa la conexión SSH',
'settings.ssh.transportIdleTtl.desc': 'Cuánto tiempo una conexión SSH compartida permanece activa después de liberar el último terminal, la exploración SFTP, la transferencia masiva o el arrendamiento de reenvío de puertos. Mientras esté activa, reconectarse al mismo host puede omitir la reautenticación (incluida la MFA). Las sesiones y transferencias activas nunca se cierran.',
'settings.ssh.transportIdleTtl.1m': '1 minuto',
'settings.ssh.transportIdleTtl.5m': '5 minutos',
'settings.ssh.transportIdleTtl.15m': '15 minutos',
'settings.ssh.transportIdleTtl.30m': '30 minutos',
'settings.ssh.transportIdleTtl.never': 'Hasta que se cierre la aplicación',
'settings.sftp.defaultOpener': 'Aplicación predeterminada para abrir archivos',
'settings.sftp.defaultOpener.desc': 'Elige la aplicación predeterminada para abrir archivos sin una asociación de archivos específica',
'settings.sftp.defaultOpener.ask': 'Preguntar siempre',
'settings.sftp.defaultOpener.askDesc': 'Muestra un diálogo para elegir una aplicación cada vez',
'settings.sftp.defaultOpener.builtInDesc': 'Abrir archivos de texto en el editor integrado por defecto',
'settings.sftp.defaultOpener.systemApp': 'Elegir aplicación...',
'settings.sftp.defaultOpener.systemAppDesc': 'Abrir archivos con una aplicación específica por defecto',
'settings.sftpFileAssociations.title': 'Asociaciones de archivos SFTP',
'settings.sftpFileAssociations.desc': 'Configura las aplicaciones predeterminadas para abrir archivos por extensión',
'settings.sftpFileAssociations.extension': 'Extensión',
'settings.sftpFileAssociations.application': 'Aplicación',
'settings.sftpFileAssociations.noAssociations': 'No hay asociaciones de archivos configuradas',
'settings.sftpFileAssociations.remove': 'Quitar',
'settings.sftpFileAssociations.removeConfirm': '¿Quitar la asociación para .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Comportamiento del doble clic',
'settings.sftp.doubleClickBehavior.desc': 'Elige la acción al hacer doble clic en un archivo en la vista SFTP',
'settings.sftp.doubleClickBehavior.open': 'Abrir archivo',
'settings.sftp.doubleClickBehavior.transfer': 'Transferir al otro panel',
'settings.sftp.doubleClickBehavior.openDesc': 'Abrir el archivo en la aplicación predeterminada',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Explorador de Windows',
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
'settings.jmsDeepLink.title': 'Enlaces de JumpServer',
'settings.jmsDeepLink.enable': 'Manejar enlaces jms://',
'settings.jmsDeepLink.enableDesc': 'Registra a Netcatty como el controlador de los enlaces jms:// del cliente de JumpServer. Puede entrar en conflicto con el cliente oficial de JumpServer.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Sincronización automática con el remoto',
'settings.sftp.autoSync.desc': 'Sincroniza automáticamente los cambios de archivos de vuelta al servidor remoto al abrir archivos con aplicaciones externas',
'settings.sftp.autoSync.enable': 'Habilitar la sincronización automática',
'settings.sftp.autoSync.enableDesc': 'Cuando guardas un archivo en una aplicación externa, los cambios se subirán automáticamente al servidor remoto',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Abrir la barra lateral automáticamente al conectar',
'settings.sftp.autoOpenSidebar.desc': 'Abre automáticamente la barra lateral del explorador de archivos SFTP al conectar a un host',
'settings.sftp.autoOpenSidebar.enable': 'Habilitar la apertura automática de la barra lateral',
'settings.sftp.autoOpenSidebar.enableDesc': 'La barra lateral de SFTP se abrirá automáticamente cuando una sesión de terminal se conecte a un host remoto',
'settings.sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
'settings.sftp.followTerminalCwd.desc': 'Sincroniza automáticamente el explorador SFTP de la barra lateral con el directorio de trabajo de la terminal (alternar en la barra de herramientas)',
'settings.sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal por defecto',
'settings.sftp.followTerminalCwd.enableDesc': 'Cuando la barra lateral de SFTP está abierta, el modo de seguimiento permanece activado por defecto y se actualiza después de los comandos cd de la terminal',
'settings.sftp.defaultViewMode': 'Modo de vista predeterminado',
'settings.sftp.defaultViewMode.desc': 'Elige el modo de vista predeterminado al abrir una pestaña SFTP nueva. Las preferencias por host anulan esta configuración.',
'settings.sftp.defaultViewMode.list': 'Vista de lista',
'settings.sftp.defaultViewMode.listDesc': 'Muestra los archivos en una lista plana del directorio actual',
'settings.sftp.defaultViewMode.tree': 'Vista de árbol',
'settings.sftp.defaultViewMode.treeDesc': 'Muestra los archivos en una estructura de árbol jerárquica',
'sftp.autoSync.success': 'Archivo sincronizado con el remoto: {fileName}',
'sftp.autoSync.error': 'No se pudo sincronizar el archivo: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Subiendo {current} de {total} archivos...',
'sftp.upload.uploading': 'Subiendo...',
'sftp.upload.compressing': 'Comprimiendo...',
'sftp.upload.extracting': 'Extrayendo...',
'sftp.upload.scanning': 'Escaneando archivos...',
'sftp.upload.completed': 'Completado',
'sftp.upload.compressed': 'Transferencia comprimida',
'sftp.upload.currentFile': 'Actual: {fileName}',
'sftp.upload.cancelled': 'Subida cancelada',
'sftp.upload.noFiles': 'No hay archivos para subir',
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
'sftp.upload.cancel': 'Cancelar',
'sftp.upload.completedToPath': 'Subido a {path}',
// SFTP Download
'sftp.download.completed': 'Descargado',
'sftp.download.cancelled': 'Descarga cancelada',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconectando...',
'sftp.reconnecting.desc': 'Se perdió la conexión, intentando reconectar',
'sftp.reconnected': 'Conexión restablecida',
'sftp.error.reconnectFailed': 'No se pudo reconectar. Inténtalo de nuevo.',
'sftp.error.connectionLostManual': 'Se perdió la conexión. Vuelve a conectar manualmente.',
'sftp.error.connectionLostReconnecting': 'Se perdió la conexión. Reconectando...',
'sftp.error.sessionLost': 'Se perdió la sesión SFTP. Vuelve a conectar.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Mostrar archivos ocultos',
'settings.sftp.showHiddenFiles.desc': 'Muestra los archivos ocultos (dotfiles en Unix/macOS y archivos con el atributo oculto en Windows) en el explorador de archivos SFTP.',
'settings.sftp.showHiddenFiles.enable': 'Mostrar archivos ocultos',
'settings.sftp.showHiddenFiles.enableDesc': 'Muestra los archivos ocultos al explorar sistemas de archivos locales y remotos',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Transferencia de carpetas comprimidas',
'settings.sftp.compressedUpload.desc': 'Comprime las carpetas antes de subirlas para reducir significativamente el tiempo de transferencia.',
'settings.sftp.compressedUpload.enable': 'Habilitar la compresión de carpetas',
'settings.sftp.compressedUpload.enableDesc': 'Comprime automáticamente las carpetas con tar antes de la transferencia. Requiere soporte de tar en el servidor. Si no está disponible, se recurre a una transferencia normal.',
'settings.sftp.skipUnchanged.enable': 'Omitir archivos sin cambios',
'settings.sftp.skipUnchanged.enableDesc': 'Omite los archivos cuyo tamaño y hora de modificación ya coinciden con el destino (estilo rsync). Deshabilitado al reemplazar una carpeta en una ruta de staging.',
// Quick Switcher
'qs.search.placeholder': 'Buscar hosts o pestañas',
'qs.jumpTo': 'Ir a',
'qs.localTerminal': 'Terminal local',
'qs.localShells': 'Shells locales',
'qs.default': 'Predeterminado',
// Select Host panel
'selectHost.title': 'Seleccionar host',
'selectHost.noHostsFound': 'No se encontraron hosts',
'selectHost.newHost': 'Host nuevo',
'selectHost.continue': 'Continuar',
'selectHost.continueWithCount': 'Continuar ({count} seleccionados)',
'selectHost.toggleGroup': 'Alternar todos los hosts en {name}',
'selectHost.toggleHost': 'Alternar {name}',
// Quick Connect
'quickConnect.knownHost.title': '¿Estás seguro de que quieres conectarte?',
'quickConnect.knownHost.authenticity': 'No se puede establecer la autenticidad de {hostname}.',
'quickConnect.knownHost.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
'quickConnect.knownHost.addQuestion': '¿Quieres agregarlo a la lista de hosts conocidos?',
'quickConnect.knownHost.addAndContinue': 'Agregar y continuar',
'quickConnect.addKey': 'Agregar clave',
'quickConnect.warning.unparsedOptions': 'Se ignoraron algunos argumentos SSH: {options}',
'quickConnect.identity.label': 'Preajuste de credenciales',
'quickConnect.identity.placeholder': 'Elige una identidad guardada',
'quickConnect.identity.empty': 'No se encontraron identidades guardadas',
'quickConnect.identity.hint': 'Elige una identidad para completar automáticamente su nombre de usuario y autenticación.',
'quickConnect.identity.selectedHint': 'Listo para conectar como {username}.',
'quickConnect.identity.method.password': 'Contraseña',
'quickConnect.identity.method.key': 'Clave SSH',
'quickConnect.identity.method.certificate': 'Certificado',
'quickConnect.identity.unavailable': 'A esta identidad le faltan credenciales utilizables en este dispositivo.',
'quickConnect.identity.useCustom': 'Usar credenciales personalizadas',
'quickConnect.identity.connect': 'Conectar con preajuste',
'quickConnect.connectTitle': 'Conectar a {host}',
// Terminal
'terminal.connectionErrorTitle': 'Error de conexión',
// Protocol select dialog
'protocolSelect.chooseProtocol': 'Elegir protocolo',
'protocolSelect.port': 'puerto:',
// Host Details
'hostDetails.title.details': 'Detalles del host',
'hostDetails.title.new': 'Host nuevo',
'vault.panel.resizeWidth': 'Redimensionar el ancho del panel',
'hostDetails.saveAria': 'Guardar',
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'Configuración de SFTP',
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
'hostDetails.sftp.fileProtocol.auto': 'Auto',
'hostDetails.sftp.fileProtocol.sftp': 'Solo SFTP',
'hostDetails.sftp.fileProtocol.scp': 'Modo SCP',
'hostDetails.sftp.sudo': 'Modo Sudo',
'hostDetails.sftp.sudo.desc': 'Adquiere automáticamente privilegios de root usando la contraseña guardada',
'hostDetails.sftp.sudo.passwordWarning': 'El modo Sudo requiere una contraseña. Configura una arriba, o asegúrate de que el servidor permita sudo sin contraseña.',
'hostDetails.sftp.encoding': 'Codificación de nombres de archivo',
'hostDetails.sftp.encoding.desc': 'Selecciona la codificación utilizada para decodificar y enviar los nombres de archivo SFTP.',
'hostDetails.label.placeholder': 'Etiqueta (p. ej., servidor de producción)',
'hostDetails.notes.label': 'Notas',
'hostDetails.notes.placeholder': 'Hardware, proyecto, cliente, región, rol...',
'hostDetails.notes.help': 'Admite Markdown. No guardes contraseñas ni claves privadas aquí.',
'hostDetails.notes.tab.edit': 'Editar',
'hostDetails.notes.tab.preview': 'Vista previa',
'hostDetails.notes.preview.empty': 'Aún no hay nada para previsualizar.',
'hostDetails.notes.toggle.show': 'Mostrar el editor de notas',
'hostDetails.notes.toggle.hide': 'Ocultar el editor de notas',
'hostDetails.group.placeholder': 'Grupo padre',
'hostDetails.section.credentials': 'Credenciales',
'hostDetails.section.portCredentials': 'Puerto y credenciales',
'hostDetails.section.appearance': 'Apariencia',
'hostDetails.distro.title': 'Distribución de Linux',
'hostDetails.distro.desc': 'Detecta automáticamente al conectar, o anula el ícono de la distro manualmente.',
'hostDetails.icon.title': 'Ícono del host',
'hostDetails.icon.sectionTitle': 'Color e ícono',
'hostDetails.icon.desc': 'Elige la fuente y el color del ícono para este host.',
'hostDetails.icon.source': 'Fuente',
'hostDetails.icon.manualLabel': 'Ícono manual',
'hostDetails.icon.tab.brand': 'Marca',
'hostDetails.icon.tab.type': 'Tipo',
'hostDetails.icon.mode.auto': 'Automático',
'hostDetails.icon.mode.custom': 'Personalizado',
'hostDetails.icon.reset': 'Restablecer el ícono del host',
'hostDetails.icon.colorLabel': 'Color del ícono',
'hostDetails.icon.colorDefault': 'Predeterminado',
'hostDetails.icon.colorAuto': 'Automático',
'hostDetails.icon.colorManual': 'Manual',
'hostDetails.icon.colorChoice': 'Color',
'hostDetails.icon.autoColorLabel': 'Color actual',
'hostDetails.icon.preview': 'Vista previa del ícono',
'hostDetails.icon.showLibrary': 'Mostrar la biblioteca de íconos',
'hostDetails.icon.hideLibrary': 'Ocultar la biblioteca de íconos',
'hostDetails.icon.autoUsesDistro': 'Usa el ícono de la distribución de Linux y el color seleccionado para este host.',
'hostDetails.icon.customOverridesDistro': 'El ícono integrado reemplaza la distribución de Linux para este host.',
'hostDetails.icon.option.server': 'Servidor',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Base de datos',
'hostDetails.icon.option.cloud': 'Nube',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Escudo',
'hostDetails.icon.option.code': 'Código',
'hostDetails.icon.option.box': 'Caja',
'hostDetails.icon.option.globe': 'Globo',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Almacenamiento',
'hostDetails.icon.option.network': 'Red',
'hostDetails.icon.option.wifi': 'Inalámbrico',
'hostDetails.icon.option.lock': 'Bloqueo',
'hostDetails.icon.option.key': 'Clave',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Contenedor',
'hostDetails.icon.option.activity': 'Actividad',
'hostDetails.icon.option.zap': 'Rápido',
'hostDetails.icon.option.server-cog': 'Configuración del servidor',
'hostDetails.icon.color.blue': 'Azul',
'hostDetails.icon.color.green': 'Verde',
'hostDetails.icon.color.red': 'Rojo',
'hostDetails.icon.color.amber': 'Ámbar',
'hostDetails.icon.color.purple': 'Púrpura',
'hostDetails.icon.color.cyan': 'Cian',
'hostDetails.icon.color.orange': 'Naranja',
'hostDetails.icon.color.slate': 'Pizarra',
'hostDetails.icon.color.violet': 'Violeta',
'hostDetails.icon.color.pink': 'Rosa',
'hostDetails.icon.color.rose': 'Rosado',
'hostDetails.icon.color.lime': 'Lima',
'hostDetails.icon.color.teal': 'Verde azulado',
'hostDetails.icon.color.sky': 'Celeste',
'hostDetails.icon.color.indigo': 'Índigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.icon.color.custom': 'Color personalizado',
'hostDetails.distro.mode': 'Fuente',
'hostDetails.distro.mode.auto': 'Detección automática',
'hostDetails.distro.mode.manual': 'Anulación manual',
'hostDetails.distro.detectedLabel': 'Actual',
'hostDetails.distro.manualLabel': 'Anulación',
'hostDetails.distro.pending': 'Detectar después de la primera conexión',
'hostDetails.distro.unknown': 'Desconocido',
'hostDetails.distro.option.linux': 'Linux genérico',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.freebsd': 'FreeBSD',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'Puerto del servidor ET',
'hostDetails.et.port.desc': 'Puerto en el que escucha etserver (predeterminado 2022)',
'hostDetails.username.placeholder': 'Nombre de usuario',
'hostDetails.password.placeholder': 'Contraseña',
'hostDetails.password.show': 'Mostrar contraseña',
'hostDetails.password.hide': 'Ocultar contraseña',
'hostDetails.password.save': 'Guardar contraseña',
'hostDetails.auth.method': 'Método de inicio de sesión',
'hostDetails.auth.auto': 'Automático',
'hostDetails.auth.auto.desc': 'Intenta con el agente SSH y las claves locales, y luego usa la contraseña guardada.',
'hostDetails.auth.passwordOnly': 'Solo contraseña',
'hostDetails.auth.password.desc': 'Usa solo la contraseña guardada y las solicitudes interactivas de contraseña.',
'hostDetails.auth.key': 'Clave',
'hostDetails.auth.key.desc': 'Usa la clave seleccionada, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.certificate': 'Certificado',
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
'hostDetails.identity.suggestions': 'Identidades',
'hostDetails.identity.missing': 'Identidad no encontrada',
'hostDetails.credential.keyCertificate': 'Clave, certificado, archivo de clave local',
'hostDetails.credential.key': 'Clave',
'hostDetails.credential.certificate': 'Certificado',
'hostDetails.credential.localKeyFile': 'Archivo de clave local',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Explorar...',
'hostDetails.credential.missing': 'Credencial no encontrada',
'hostDetails.section.systemSshAgent': 'Agente SSH del sistema',
'hostDetails.systemSshAgent': 'Usar el agente SSH del sistema para iniciar sesión',
'hostDetails.systemSshAgent.desc': 'Usa claves ya desbloqueadas por macOS, Linux, Windows, 1Password u otro agente SSH compatible',
'hostDetails.systemSshAgent.socket': 'Socket del agente',
'hostDetails.systemSshAgent.socket.desc': 'Déjalo vacío para usar SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Usar solo las claves configuradas',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'No intentar con claves no relacionadas actualmente cargadas en el agente',
'hostDetails.systemSshAgent.notRunning': 'El agente SSH del sistema no está disponible',
'hostDetails.systemSshAgent.notRunningHint': 'Inicia o desbloquea tu agente SSH, o proporciona la ruta de su socket arriba.',
'hostDetails.keys.search': 'Buscar claves...',
'hostDetails.keys.empty': 'No hay claves disponibles',
'hostDetails.certs.search': 'Buscar certificados...',
'hostDetails.certs.empty': 'No hay certificados disponibles',
'hostDetails.agentForwarding': 'Reenviar el agente SSH',
'hostDetails.agentForwarding.desc': 'Permite que el servidor remoto use tus claves SSH locales (p. ej., para operaciones de git)',
'hostDetails.agentForwarding.agentNotRunning': 'El agente SSH no está disponible',
'hostDetails.agentForwarding.agentNotRunningHint': 'No se detectó ningún agente SSH. Habilita el agente de autenticación de OpenSSH en los servicios de Windows, o usa un agente compatible como Bitwarden, 1Password o gpg-agent.',
'hostDetails.section.agentForwarding': 'Agente SSH',
'hostDetails.x11Forwarding': 'Reenviar aplicaciones X11',
'hostDetails.x11Forwarding.desc': 'Muestra las aplicaciones gráficas remotas en tu escritorio local cuando se ejecuta un servidor X local.',
'hostDetails.section.x11Forwarding': 'Reenvío X11',
'hostDetails.section.deviceType': 'Tipo de dispositivo',
'hostDetails.deviceType': 'Modo de dispositivo de red',
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
'hostDetails.lineTimestamps.desc': 'Muestra la hora local junto a las líneas de salida visibles de este host sin cambiar el texto de la terminal.',
'hostDetails.legacyAlgorithms': 'Permitir algoritmos heredados',
'hostDetails.legacyAlgorithms.desc': 'Habilita algoritmos SSH obsoletos (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) para conectar con equipos de red más antiguos.',
'hostDetails.legacyAlgorithms.warning': 'Estos algoritmos tienen debilidades de seguridad conocidas. Habilítalos solo para dispositivos heredados que no admiten criptografía moderna.',
'hostDetails.skipEcdsaHostKey': 'Omitir la clave de host ECDSA',
'hostDetails.skipEcdsaHostKey.desc': 'Algunos switches antiguos de Huawei / Cisco generan firmas de clave de host ECDSA no estándar que provocan "signature verification failed". Al activar esto, se eliminan todos los ecdsa-sha2-* de la oferta del cliente para que la negociación recurra a RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Anulaciones avanzadas de algoritmos',
'hostDetails.algorithms.advanced.desc': 'Reemplaza la lista de algoritmos ofrecidos para cualquier categoría por cada host. Dejar una categoría sin cambios usa la predeterminada; seleccionar un subconjunto reemplaza por completo la lista predeterminada. Los valores incorrectos pueden hacer que el host sea inalcanzable.',
'hostDetails.algorithms.inheritedNotice': 'El grupo actual tiene anulaciones de algoritmos configuradas para: {categories}. El botón "Restablecer" aquí recurre a las listas del grupo, no a las predeterminadas de Netcatty. Para ignorar la restricción del grupo, borra la anulación en la configuración de algoritmos del grupo.',
'hostDetails.algorithms.customized': 'personalizado',
'hostDetails.algorithms.reset': 'Restablecer',
'hostDetails.algorithms.category.kex': 'Intercambio de claves (KEX)',
'hostDetails.algorithms.category.cipher': 'Cifrado',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Clave de host',
'hostDetails.algorithms.category.compress': 'Compresión',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Anular el keepalive global',
'hostDetails.keepalive.desc': 'Usa una política de keepalive personalizada para este host en lugar de la configuración global. Útil para routers o switches antiguos cuyo servidor SSH no responde a las solicitudes keepalive@openssh.com: establece el intervalo en 0 para deshabilitar el keepalive por completo en este host.',
'hostDetails.keepalive.interval': 'Intervalo (segundos)',
'hostDetails.keepalive.countMax': 'Máximo de keepalives sin respuesta',
'hostDetails.keepalive.disabledHint': 'Intervalo = 0 deshabilita el keepalive para este host. La sesión dependerá de los tiempos de espera a nivel de TCP para detectar una conexión caída.',
'hostDetails.section.sshTimeouts': 'Tiempos de espera de conexión SSH',
'hostDetails.sshTimeouts.tcpConnect': 'Tiempo de espera de TCP',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
'hostDetails.jumpHosts.hops': '{count} salto(s)',
'hostDetails.jumpHosts.direct': 'Directo',
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxy.none': 'Ninguno',
'hostDetails.proxy.edit': 'Editar proxy',
'hostDetails.proxy.configure': 'Configurar proxy',
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxyPanel.hostPlaceholder': 'Host del proxy',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Usa %h para el host de destino, %p para el puerto de destino y %% para un porcentaje literal.',
'hostDetails.proxyPanel.credentials': 'Credenciales',
'hostDetails.proxyPanel.usernamePlaceholder': 'Nombre de usuario',
'hostDetails.proxyPanel.passwordPlaceholder': 'Contraseña',
'hostDetails.proxyPanel.identities': 'Identidades',
'hostDetails.proxyPanel.keychainIdentity': 'Identidad del llavero',
'hostDetails.proxyPanel.manualCredentials': 'Credenciales manuales',
'hostDetails.proxyPanel.remove': 'Quitar proxy',
'hostDetails.proxyPanel.savedProxy': 'Proxy guardado',
'hostDetails.proxyPanel.selectSaved': 'Seleccionar proxy guardado',
'hostDetails.proxyPanel.customProxy': 'Proxy personalizado',
'hostDetails.proxyPanel.missing': 'Faltante',
'hostDetails.proxyPanel.missingSaved': 'Falta el proxy guardado',
'hostDetails.proxyPanel.missingIdentity': 'Falta la identidad del llavero',
'hostDetails.proxyPanel.incompleteIdentity': 'La identidad del proxy necesita un nombre de usuario y una contraseña',
'hostDetails.proxyPanel.unreadableIdentity': 'No se puede leer la contraseña de la identidad del proxy en este dispositivo',
'hostDetails.proxyPanel.error.required': 'Se requieren el host y el puerto del proxy, o un ProxyCommand.',
'hostDetails.envVars': 'Variables de entorno',
'hostDetails.envVars.add': 'Agregar variable de entorno',
'hostDetails.envVars.title': 'Variables de entorno',
'hostDetails.envVars.desc': 'Establece una variable de entorno para {host}.',
'hostDetails.envVars.note':
'Algunos servidores SSH por defecto solo permiten variables con el prefijo LC_ y LANG_.',
'hostDetails.envVars.variable': 'Variable',
'hostDetails.envVars.value': 'Valor',
'hostDetails.envVars.newVariable': 'Variable nueva',
'hostDetails.envVars.variableName': 'Nombre de la variable',
'hostDetails.chain.title': 'Editar cadena',
'hostDetails.chain.desc': 'Agregar otro host creará una conexión a {host}.',
'hostDetails.chain.addHost': 'Agregar un host',
'hostDetails.chain.target': 'Destino',
'hostDetails.chain.availableHosts': 'Hosts disponibles',
'hostDetails.chain.clear': 'Limpiar',
'hostDetails.group.title': 'Grupo nuevo',
'hostDetails.group.general': 'General',
'hostDetails.group.namePlaceholder': 'Nombre del grupo',
'hostDetails.group.parentPlaceholder': 'Grupo padre',
'hostDetails.group.cloudSync': 'Sincronización en la nube',
'hostDetails.group.addProtocol': 'Agregar protocolo',
'hostDetails.startupCommand': 'Comando de inicio',
'hostDetails.startupCommand.placeholder': 'Comando para ejecutar al conectar (p. ej., cd /app && ls)',
'hostDetails.startupCommand.help':
'Este comando se ejecutará automáticamente después de que se establezca la conexión SSH.',
'hostDetails.startupCommand.runMode': 'Ejecución multilínea',
'hostDetails.startupCommand.runMode.help': 'Elige cómo se envían los comandos de inicio multilínea después de la conexión.',
'hostDetails.startupCommand.runMode.lineDelay': 'Enviar línea por línea',
'hostDetails.startupCommand.runMode.paste': 'Enviar todo de una vez',
'hostDetails.otherProtocols': 'Otros protocolos',
'hostDetails.telnetOn': 'Telnet activado',
'hostDetails.port': 'puerto',
'hostDetails.telnet.credentials': 'Credenciales',
'hostDetails.telnet.identity.placeholder': 'Identidad manual',
'hostDetails.telnet.username': 'Nombre de usuario de Telnet',
'hostDetails.telnet.password': 'Contraseña de Telnet',
'hostDetails.charset.placeholder': 'Codificación (p. ej., UTF-8)',
'hostDetails.telnet.add': 'Agregar protocolo Telnet',
'hostDetails.telnet.setDefault': 'Conectar con Telnet por defecto',
'hostDetails.tags': 'Etiquetas',
'hostDetails.group': 'Grupo',
'hostDetails.selectGroup': 'Seleccionar grupo',
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
'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',
'hostDetails.plugin.authentication.placeholder': 'Selecciona un proveedor de autenticación',
'hostDetails.plugin.authentication.none': 'Administrada por el proveedor o sin autenticación',
'hostDetails.plugin.credential.placeholder': 'Selecciona una credencial del host',
'hostDetails.plugin.credential.none': 'No hay credencial guardada',
'hostDetails.plugin.credential.password': 'Identidad de contraseña',
'hostDetails.plugin.credential.privateKey': 'Clave privada',
'hostDetails.plugin.credential.unavailable': 'Credencial guardada no disponible',
'hostDetails.plugin.configuration.label': 'Configuración del proveedor (JSON)',
'hostDetails.plugin.configuration.invalid': 'La configuración debe ser un JSON válido.',
'hostDetails.plugin.configuration.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
'hostDetails.plugin.unavailable': 'El proveedor configurado no está disponible. La configuración se conserva hasta que se restaure el plugin.',
'hostDetails.plugin.useSsh': 'Usar SSH en su lugar',
// Host form (legacy modal)
'hostForm.title.edit': 'Editar host',
'hostForm.title.new': 'Host nuevo',
'hostForm.desc.edit': 'Actualiza los detalles de conexión de este host',
'hostForm.desc.new': 'Crea una nueva entrada de host SSH',
'hostForm.field.label': 'Etiqueta',
'hostForm.placeholder.label': 'Mi servidor de producción',
'hostForm.field.hostname': 'Nombre de host / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Puerto',
'hostForm.field.username': 'Nombre de usuario',
'hostForm.field.osType': 'Tipo de SO',
'hostForm.placeholder.selectOs': 'Seleccionar SO',
'hostForm.field.group': 'Grupo',
'hostForm.placeholder.group': 'p. ej., AWS, DigitalOcean',
'hostForm.field.tags': 'Etiquetas',
'hostForm.placeholder.addTag': 'Agregar una etiqueta...',
'hostForm.auth.method': 'Método de autenticación',
'hostForm.auth.password': 'Contraseña',
'hostForm.auth.sshKey': 'Clave SSH',
'hostForm.auth.selectKey': 'Seleccionar una clave SSH',
'hostForm.auth.noKeys': 'No hay claves disponibles',
'hostForm.auth.noKeysHint': 'No se encontraron claves SSH en el llavero. Crea una primero.',
'hostForm.saveHost': 'Guardar host',
// Connection logs
'logs.table.date': 'Fecha',
'logs.table.user': 'Usuario',
'logs.table.host': 'Host',
'logs.table.saved': 'Guardado',
'logs.empty.title': 'No hay registros de conexión',
'logs.empty.desc':
'Tu historial de conexiones aparecerá aquí cuando te conectes a hosts o abras terminales locales.',
'logs.loadMore': 'Cargar {count} registros más',
'logs.ongoing': 'en curso',
'logs.localTerminal': 'Terminal local',
'logs.action.save': 'Guardar',
'logs.action.unsave': 'Quitar de guardados',
'logs.action.delete': 'Eliminar',
// Log view
'logView.customizeAppearance': 'Personalizar la apariencia',
'logView.appearance': 'Apariencia',
'logView.readOnly': 'Solo lectura',
'logView.export': 'Exportar',
'vault.import.plugins.title': 'Importadores de plugins',
'vault.import.plugins.loading': 'Generando una vista previa de importación segura…',
'vault.import.plugins.progress': '{completed} de {total} procesados {message}',
'vault.import.plugins.preview': 'Vista previa de importación',
'vault.import.plugins.empty': 'No se devolvieron borradores importables.',
'vault.import.plugins.summary': '{parsed} borradores · {warnings} advertencias · {errors} errores',
'vault.import.plugins.commit': 'Vista previa de importación',
'vault.import.plugins.fileUnavailable': 'El archivo seleccionado no está disponible para el bridge del importador de escritorio.',
'vault.import.plugins.notRecognized': 'Este importador no reconoció el archivo seleccionado.',
'vault.import.plugins.containsErrors': 'La vista previa de importación contiene errores y no se puede confirmar.',
'vault.import.plugins.committed': 'Se importaron {count} elementos validados.',
'vault.import.plugins.duplicates': '{count} elementos duplicados se omitirán.',
'vault.import.plugins.validationErrors': '{count} borradores fallaron la validación de host y deben corregirse mediante el importador.',
'vault.import.plugins.kind.host': 'Host',
'vault.import.plugins.kind.identity': 'Identidad',
'vault.import.plugins.kind.key': 'Clave',
'vault.import.plugins.kind.snippet': 'Fragmento',
'vault.import.plugins.kind.group': 'Grupo',
'vault.import.plugins.moreItems': '{count} elementos más están incluidos en esta importación.',
'vault.import.plugins.moreDiagnostics': '{count} diagnósticos más se omitieron de esta vista previa.',
};
+24 -3
View File
@@ -24,6 +24,10 @@ export const ruAiMessages: Messages = {
'ai.providers.style.google': 'Совместимый с Google',
'ai.providers.style.inherited': 'авто',
'ai.providers.style.help': 'Определяет, какой формат API используется для запросов. Переопределите, если стороннее API использует другой диалект.',
'ai.providers.openaiApi': 'Формат API OpenAI',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': 'Chat Completions работает с большинством OpenAI-совместимых эндпоинтов. Responses может повысить попадание в кэш на ретрансляторах с поддержкой /v1/responses.',
'ai.providers.icon.change': 'Изменить иконку',
'ai.providers.icon.upload': 'Загрузить изображение',
'ai.providers.icon.reset': 'Сбросить',
@@ -35,6 +39,7 @@ export const ruAiMessages: Messages = {
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
'ai.providers.baseUrl.ollamaHelp': 'Локальный Ollama: http://localhost:11434/v1 (без API-ключа). Ollama Cloud: https://ollama.com/v1 и ключ Cloud.',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
@@ -394,15 +399,28 @@ export const ruAiMessages: Messages = {
'ai.chat.permObserver': 'Наблюдатель',
'ai.chat.permConfirm': 'Подтверждение',
'ai.chat.permAuto': 'Авто',
'ai.chat.permObserverDesc': 'Только чтение (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Спрашивать перед записью Netcatty (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Выполнять свободно (Cursor CLI: agent + force)',
'ai.chat.permObserverDesc': 'Только чтение',
'ai.chat.permConfirmDesc': 'Спрашивать перед записью',
'ai.chat.permAutoDesc': 'Выполнять свободно',
'ai.chat.emptyHint': 'Спрашивайте о ваших серверах, запускайте команды или получайте помощь с конфигурациями.',
'ai.chat.placeholder': 'Сообщение {agent} — @ для добавления контекста, / для команд',
'ai.chat.placeholderDefault': 'Сообщение агенту Catty...',
'ai.chat.noModel': 'Нет модели',
'ai.chat.noProviderModel': 'Модель по умолчанию не задана — настройте её в Настройки → AI → Провайдеры.',
'ai.chat.selectProvider': 'Выберите провайдера',
'ai.chat.selectProviderAndModel': 'Выберите провайдера и модель',
'ai.chat.selectModel': 'Выберите модель',
'ai.chat.searchModels': 'Поиск моделей',
'ai.chat.providers': 'Провайдеры',
'ai.chat.models': 'Модели',
'ai.chat.pinned': 'Закреплённые',
'ai.chat.useCustomModel': 'Использовать «{id}»',
'ai.chat.thinkingLevel': 'Мышление',
'ai.chat.thinkingOff': 'Выкл.',
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
@@ -468,6 +486,9 @@ export const ruAiMessages: Messages = {
'ai.safety.commandTimeout': 'Тайм-аут команды',
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
'ai.safety.commandTimeout.unit': 'с',
'ai.safety.responseIdleTimeout': 'Ожидание ответа встроенного ИИ',
'ai.safety.responseIdleTimeout.description': 'Отменяет запрос встроенного ИИ после указанного числа секунд без нового ответа. Не управляет общей длительностью ответа и выполнением команд.',
'ai.safety.responseIdleTimeout.unit': 'с',
'ai.safety.maxIterations': 'Макс. число итераций',
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
'ai.safety.blocklist': 'Чёрный список команд',
+90
View File
@@ -82,6 +82,32 @@ export const ruCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// App Lock
'appLock.title': 'Netcatty заблокирован',
'appLock.reason.default': 'Введите пароль блокировки, чтобы продолжить.',
'appLock.reason.startup': 'Netcatty блокируется при каждом открытии этого окна.',
'appLock.reason.idle': 'Netcatty заблокирован после периода бездействия.',
'appLock.reason.manual': 'Netcatty был заблокирован вручную.',
'appLock.passwordLabel': 'Пароль блокировки',
'appLock.passwordPlaceholder': 'Введите пароль для разблокировки',
'appLock.unlock': 'Разблокировать',
'appLock.unlocking': 'Разблокировка...',
'appLock.usePassword': 'Использовать пароль',
'appLock.error.emptyPassword': 'Введите пароль блокировки.',
'appLock.error.incorrectPassword': 'Неверный пароль блокировки.',
'appLock.systemUnlock.unlockWith': 'Разблокировать через {label}',
'appLock.systemUnlock.preparing': 'Подготовка {label}...',
'appLock.systemUnlock.verifying': 'Ожидание {label}...',
'appLock.systemUnlock.error': 'Системная разблокировка не завершена. Используйте пароль блокировки, чтобы продолжить.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Сбросить блокировку',
'appLock.reset.description': 'Это удалит текущий пароль блокировки и отключит App Lock. Новый пароль можно создать позже в настройках.',
'appLock.reset.cancel': 'Отмена',
'appLock.reset.confirm': 'Сбросить App Lock',
'appLock.reset.resetting': 'Сброс...',
'appLock.reset.error': 'Не удалось сбросить App Lock. Попробуйте ещё раз.',
'topTabs.lockApp': 'Заблокировать Netcatty',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
@@ -117,6 +143,59 @@ export const ruCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > App Lock
'settings.appLock.title': 'Блокировка приложения',
'settings.appLock.description': 'Требовать локальный пароль блокировки при открытии Netcatty, после бездействия или при ручной блокировке.',
'settings.appLock.setupTitle': 'Задайте пароль блокировки',
'settings.appLock.setupDescription': 'Создайте пароль ниже. Блокировка приложения включится сразу после сохранения.',
'settings.appLock.manageTitle': 'Блокировка приложения готова',
'settings.appLock.enabledStatus': 'Включена. Netcatty блокируется при запуске, после бездействия и при ручной блокировке.',
'settings.appLock.disabledStatus': 'Пароль сохранён, но блокировка приложения сейчас выключена.',
'settings.appLock.disableTitle': 'Отключить блокировку приложения',
'settings.appLock.disableDescription': 'Введите текущий пароль блокировки, чтобы Netcatty перестал блокироваться. Сохранённый пароль будет удалён; для повторного включения нужно создать новый.',
'settings.appLock.disable': 'Отключить блокировку',
'settings.appLock.disabling': 'Отключение...',
'settings.appLock.setupPasswordTitle': 'Создайте пароль блокировки',
'settings.appLock.setupPasswordDescription': 'Этот пароль нужен для разблокировки Netcatty, а также для изменения или отключения блокировки позже.',
'settings.appLock.changePasswordTitle': 'Изменить пароль блокировки',
'settings.appLock.changePasswordDescription': 'Введите текущий пароль блокировки, затем выберите новый.',
'settings.appLock.enable': 'Включить блокировку приложения',
'settings.appLock.enableDesc': 'Блокировать при запуске и после выбранного периода бездействия.',
'settings.appLock.timeout': 'Тайм-аут бездействия',
'settings.appLock.timeoutDesc': 'Блокировать после бездействия или при закрытии в трей. «Никогда» отключает оба варианта.',
'settings.appLock.timeout.0': 'Не блокировать автоматически',
'settings.appLock.timeout.1': '1 минута',
'settings.appLock.timeout.5': '5 минут',
'settings.appLock.timeout.15': '15 минут',
'settings.appLock.timeout.30': '30 минут',
'settings.appLock.timeout.60': '60 минут',
'settings.appLock.systemUnlock.label': 'Разблокировка через {label}',
'settings.appLock.systemUnlock.desc': 'Используйте {label} как альтернативный способ разблокировать Netcatty на этом устройстве.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} сейчас недоступен на этом устройстве.',
'settings.appLock.systemUnlock.unavailable': 'Системная разблокировка недоступна на этом устройстве.',
'settings.appLock.systemUnlock.locked': 'Разблокируйте Netcatty перед изменением системной разблокировки.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Открывать {label} автоматически',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Автоматически показывать запрос {label}, когда Netcatty ожидает разблокировки.',
'settings.appLock.currentPassword': 'Текущий пароль',
'settings.appLock.currentPasswordPlaceholder': 'Нужен для отключения или замены',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Текущий пароль для отключения блокировки',
'settings.appLock.currentPasswordForChangePlaceholder': 'Текущий пароль для изменения',
'settings.appLock.newPassword': 'Новый пароль',
'settings.appLock.newPasswordPlaceholder': 'Задайте пароль блокировки',
'settings.appLock.confirmPassword': 'Подтвердите пароль',
'settings.appLock.confirmPasswordPlaceholder': 'Повторите пароль',
'settings.appLock.savePassword': 'Задать и включить',
'settings.appLock.savingPassword': 'Сохранение...',
'settings.appLock.passwordSet': 'Пароль настроен',
'settings.appLock.replacePassword': 'Заменить пароль',
'settings.appLock.enableAfterPassword': 'Задайте пароль блокировки перед включением.',
'settings.appLock.localOnlyHint': 'Локальная приватная блокировка. Она не синхронизируется и не является системной границей безопасности.',
'settings.appLock.validation.currentRequired': 'Введите текущий пароль блокировки.',
'settings.appLock.validation.newRequired': 'Введите новый пароль блокировки.',
'settings.appLock.validation.confirmRequired': 'Подтвердите новый пароль блокировки.',
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
@@ -521,6 +600,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.autoCloseOnExit': 'Автоматически закрывать терминал при выходе',
'settings.terminal.behavior.autoCloseOnExit.desc':
'Разрешить автоматическое закрытие вкладок и окон терминала после завершения сеанса. Отключите, чтобы сохранять их после любого выхода.',
'settings.terminal.behavior.disconnectedNotice': 'При разрыве соединения',
'settings.terminal.behavior.disconnectedNotice.desc':
'Показывать компактное уведомление внутри уже подключённого терминала или полный диалог. Ошибки первого подключения по-прежнему показываются в диалоге.',
'settings.terminal.behavior.disconnectedNotice.terminal': 'В терминале',
'settings.terminal.behavior.disconnectedNotice.dialog': 'Диалог',
'settings.terminal.behavior.rightClick.menu': 'Показать меню',
'settings.terminal.behavior.rightClick.paste': 'Вставить',
'settings.terminal.behavior.rightClick.selectWord': 'Выбрать слово',
@@ -567,6 +651,12 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
+7 -1
View File
@@ -83,6 +83,9 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.paneMagnification.magnify': 'Увеличить текущую панель',
'terminal.paneMagnification.restore': 'Восстановить раскладку панелей',
'terminal.paneMagnification.hint': 'Увеличено',
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
'terminal.toolbar.dragPane': 'Перетащить панель терминала',
'terminal.toolbar.showActions': 'Показать действия терминала',
@@ -204,6 +207,9 @@ export const ruTerminalMessages: Messages = {
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
'terminal.progress.reconnecting': 'Повторное подключение...',
'terminal.progress.autoReconnectScheduled': 'Соединение потеряно. Повторное подключение через {seconds} с (попытка {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Попытка автоматического подключения {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
@@ -765,7 +771,7 @@ export const ruTerminalMessages: Messages = {
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
'snippets.shortkey.clear': 'Очистить сочетание',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с «{name}»',
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
// Serial Port
+13 -1
View File
@@ -245,6 +245,7 @@ export const ruVaultMessages: Messages = {
'sftp.columns.modified': 'Изменён',
'sftp.columns.size': 'Размер',
'sftp.columns.kind': 'Тип',
'sftp.columns.owner': 'Владелец',
'sftp.columns.configure': 'Выбрать видимые столбцы',
'sftp.columns.actions': 'Действия',
'sftp.sort.directoriesFirst': 'Папки сверху',
@@ -266,6 +267,10 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Переместить',
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.extract': 'Распаковать здесь',
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
@@ -427,6 +432,13 @@ export const ruVaultMessages: Messages = {
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.folderTitle': 'Конфликт папок',
'sftp.conflict.folderDesc': 'В месте назначения уже существует папка с таким именем',
'sftp.conflict.folderFileDesc': 'В месте назначения уже существует файл с таким именем. Папку нельзя объединить с файлом.',
'sftp.conflict.folderSymlinkDesc': 'В месте назначения уже существует символическая ссылка с таким именем. Папку нельзя объединить с символической ссылкой.',
'sftp.conflict.folderUnknownDesc': 'В месте назначения уже существует элемент с таким именем, но его тип не удалось определить. Замена недоступна для защиты данных в месте назначения.',
'sftp.conflict.folderMergeHint': 'Объединение сохраняет файлы и подпапки, которые существуют только в папке назначения.',
'sftp.conflict.folderReplaceWarning': 'Замена удалит все файлы и подпапки в папке назначения, которых нет в исходной папке. Удалённые данные нельзя восстановить.',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
'sftp.conflict.existingFile': 'Существующий файл',
'sftp.conflict.newFile': 'Новый файл',
@@ -525,7 +537,7 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
@@ -5,6 +5,8 @@ import { enSystemManagerMessages } from "./en/systemManager";
import { enTerminalMessages } from "./en/terminal";
import { ruSystemManagerMessages } from "./ru/systemManager";
import { ruTerminalMessages } from "./ru/terminal";
import { esSystemManagerMessages } from "./es/systemManager";
import { esTerminalMessages } from "./es/terminal";
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
@@ -14,6 +16,7 @@ test("SSH network latency is explicit in every locale and UI surface", () => {
const labels = [
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
[esTerminalMessages, esSystemManagerMessages, "Latencia de red SSH"],
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
] as const;
@@ -2,13 +2,22 @@ import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import { 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";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
{ name: "es", messages: es },
];
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
@@ -59,6 +68,21 @@ test("localized settings include network proxy labels", () => {
}
});
test("localized settings include OSC desktop notification labels", () => {
const keys = [
"settings.terminal.behavior.oscNotifications",
"settings.terminal.behavior.oscNotifications.desc",
"settings.terminal.behavior.oscNotifications.off",
"settings.terminal.behavior.oscNotifications.unfocused",
"settings.terminal.behavior.oscNotifications.always",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
@@ -78,6 +102,73 @@ test("localized settings include terminal font weight option labels", () => {
}
});
test("all app lock strings are translated in every supported locale", () => {
const keys = [
"appLock.title",
"appLock.reason.default",
"appLock.reason.startup",
"appLock.reason.idle",
"appLock.reason.manual",
"appLock.passwordLabel",
"appLock.passwordPlaceholder",
"appLock.unlock",
"appLock.unlocking",
"appLock.error.emptyPassword",
"appLock.error.incorrectPassword",
"appLock.systemUnlock.unlockWith",
"appLock.systemUnlock.error",
"appLock.logoLabel",
"appLock.reset.title",
"appLock.reset.description",
"appLock.reset.cancel",
"appLock.reset.confirm",
"appLock.reset.resetting",
"appLock.reset.error",
"topTabs.lockApp",
"settings.appLock.title",
"settings.appLock.description",
"settings.appLock.enable",
"settings.appLock.enableDesc",
"settings.appLock.timeout",
"settings.appLock.timeoutDesc",
"settings.appLock.timeout.0",
"settings.appLock.timeout.1",
"settings.appLock.timeout.5",
"settings.appLock.timeout.15",
"settings.appLock.timeout.30",
"settings.appLock.timeout.60",
"settings.appLock.systemUnlock.label",
"settings.appLock.systemUnlock.desc",
"settings.appLock.systemUnlock.unavailableDesc",
"settings.appLock.systemUnlock.unavailable",
"settings.appLock.systemUnlock.locked",
"settings.appLock.systemUnlock.autoPrompt.label",
"settings.appLock.systemUnlock.autoPrompt.desc",
"settings.appLock.currentPassword",
"settings.appLock.currentPasswordPlaceholder",
"settings.appLock.newPassword",
"settings.appLock.newPasswordPlaceholder",
"settings.appLock.confirmPassword",
"settings.appLock.confirmPasswordPlaceholder",
"settings.appLock.savePassword",
"settings.appLock.savingPassword",
"settings.appLock.passwordSet",
"settings.appLock.replacePassword",
"settings.appLock.enableAfterPassword",
"settings.appLock.localOnlyHint",
"settings.appLock.validation.currentRequired",
"settings.appLock.validation.newRequired",
"settings.appLock.validation.confirmRequired",
"settings.appLock.validation.mismatch",
"settings.appLock.validation.incorrect",
];
for (const locale of APP_LOCK_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { enVaultMessages } from './en/vault.ts';
import { esVaultMessages } from './es/vault.ts';
import { ruVaultMessages } from './ru/vault.ts';
import { zhCNVaultMessages } from './zh-CN/vault.ts';
import { zhTWVaultMessages } from './zh-TW/vault.ts';
const FOLDER_CONFLICT_KEYS = [
'sftp.conflict.folderTitle',
'sftp.conflict.folderDesc',
'sftp.conflict.folderFileDesc',
'sftp.conflict.folderSymlinkDesc',
'sftp.conflict.folderUnknownDesc',
'sftp.conflict.folderMergeHint',
'sftp.conflict.folderReplaceWarning',
] as const;
test('folder conflict safety copy exists in every supported locale', () => {
const locales = {
en: enVaultMessages,
es: esVaultMessages,
ru: ruVaultMessages,
'zh-CN': zhCNVaultMessages,
'zh-TW': zhTWVaultMessages,
};
for (const [locale, messages] of Object.entries(locales)) {
for (const key of FOLDER_CONFLICT_KEYS) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key]?.trim(), '', `${locale} has an empty ${key}`);
}
}
});
@@ -3,6 +3,7 @@ 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';
@@ -14,12 +15,23 @@ const KEYS = [
] as const;
test('snippet bulk-delete copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
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 snippet bulk-delete copy`);
}
});
test('snippet shortkey system-conflict copy names the conflicting action', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const text = messages['snippets.shortkey.error.systemConflict'];
assert.match(
text ?? '',
/\{name\}/,
`${locale} system-conflict copy should include {name}`,
);
}
});
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
assert.equal(
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
@@ -3,6 +3,7 @@ import test from 'node:test';
import en from './en';
import ru from './ru';
import es from './es';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
@@ -14,7 +15,7 @@ const keys = [
] as const;
test('terminal host management strings exist in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const [locale, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has an empty ${key}`);
@@ -3,6 +3,7 @@ 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";
@@ -29,6 +30,7 @@ const INLINE_IMAGE_KEYS = [
const LOCALES = [
{ name: "en", messages: en },
{ name: "es", messages: es },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
{ name: "ru", messages: ru },
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
const osc7Keys = [
@@ -19,7 +20,7 @@ const osc7Keys = [
] as const;
test("OSC 7 setup copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
for (const key of osc7Keys) {
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { enTerminalMessages } from "./en/terminal";
import { esTerminalMessages } from "./es/terminal";
import { ruTerminalMessages } from "./ru/terminal";
import { zhCNTerminalMessages } from "./zh-CN/terminal";
import { zhTWTerminalMessages } from "./zh-TW/terminal";
test("terminal reconnect notices are localized in every bundled language", () => {
const messages = [
enTerminalMessages,
esTerminalMessages,
ruTerminalMessages,
zhCNTerminalMessages,
zhTWTerminalMessages,
];
const keys = [
"terminal.progress.enterReconnectHint",
"terminal.progress.reconnecting",
"terminal.progress.autoReconnectScheduled",
"terminal.progress.autoReconnectAttempt",
];
for (const locale of messages) {
for (const key of keys) {
assert.equal(typeof locale[key], "string", `missing ${key}`);
assert.notEqual(locale[key]?.trim(), "", `empty ${key}`);
}
}
});
@@ -3,6 +3,7 @@ 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";
@@ -26,7 +27,7 @@ const KEYS = [
] as const;
test("bulk vault import and group-selection copy exists in every locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
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 bulk vault labels`);
}
+24 -3
View File
@@ -43,6 +43,10 @@ export const zhCNAiMessages: Messages = {
'ai.providers.style.google': 'Google 兼容',
'ai.providers.style.inherited': '默认',
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
'ai.providers.openaiApi': 'OpenAI 请求格式',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': '默认 Chat Completions,兼容大多数 OpenAI 兼容接口。部分中转站在 Responses(/v1/responses)上缓存命中率更高。',
'ai.providers.icon.change': '修改图标',
'ai.providers.icon.upload': '上传图片',
'ai.providers.icon.reset': '恢复默认',
@@ -54,6 +58,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
'ai.providers.baseUrl.ollamaHelp': '本地 Ollama:http://localhost:11434/v1(无需 API Key)。Ollama Cloud:https://ollama.com/v1,并填写 Cloud API Key。',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
@@ -442,15 +447,28 @@ export const zhCNAiMessages: Messages = {
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自动',
'ai.chat.permObserverDesc': '只读(Cursor CLI:Ask)',
'ai.chat.permConfirmDesc': 'Netcatty 写操作前确认(Cursor CLI:agent + force)',
'ai.chat.permAutoDesc': '自由执行(Cursor CLI:agent + force)',
'ai.chat.permObserverDesc': '只读',
'ai.chat.permConfirmDesc': '写操作前确认',
'ai.chat.permAutoDesc': '自由执行',
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
'ai.chat.selectProvider': '选择提供商',
'ai.chat.selectProviderAndModel': '选择提供商和模型',
'ai.chat.selectModel': '选择模型',
'ai.chat.searchModels': '搜索模型',
'ai.chat.providers': '提供商',
'ai.chat.models': '模型',
'ai.chat.pinned': '已固定',
'ai.chat.useCustomModel': '使用 “{id}”',
'ai.chat.thinkingLevel': '思考强度',
'ai.chat.thinkingOff': '关闭',
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
@@ -516,6 +534,9 @@ export const zhCNAiMessages: Messages = {
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.responseIdleTimeout': '内置 AI 响应等待时间',
'ai.safety.responseIdleTimeout.description': '内置 AI 请求连续这么多秒没有收到新响应时会被取消。这个设置不控制整段响应总时长,也不影响命令超时。',
'ai.safety.responseIdleTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
+84
View File
@@ -66,6 +66,32 @@ export const zhCNCoreMessages: Messages = {
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// App Lock
'appLock.title': 'Netcatty 已锁定',
'appLock.reason.default': '输入锁定密码以继续。',
'appLock.reason.startup': '每次打开此窗口时,Netcatty 都会锁定。',
'appLock.reason.idle': 'Netcatty 已因长时间无操作而锁定。',
'appLock.reason.manual': 'Netcatty 已被手动锁定。',
'appLock.passwordLabel': '锁定密码',
'appLock.passwordPlaceholder': '输入密码解锁',
'appLock.unlock': '解锁',
'appLock.unlocking': '正在解锁...',
'appLock.usePassword': '使用密码解锁',
'appLock.error.emptyPassword': '请输入锁定密码。',
'appLock.error.incorrectPassword': '锁定密码不正确。',
'appLock.systemUnlock.unlockWith': '使用 {label} 解锁',
'appLock.systemUnlock.preparing': '正在准备 {label}...',
'appLock.systemUnlock.verifying': '正在等待 {label}...',
'appLock.systemUnlock.error': '系统解锁未完成。请使用锁定密码继续。',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': '重置应用锁定',
'appLock.reset.description': '这会删除当前锁定密码并关闭应用锁定。需要再次使用时,请到设置里创建新密码。',
'appLock.reset.cancel': '取消',
'appLock.reset.confirm': '重置应用锁定',
'appLock.reset.resetting': '正在重置...',
'appLock.reset.error': '无法重置应用锁定,请重试。',
'topTabs.lockApp': '锁定 Netcatty',
// Settings shell
'settings.title': '设置',
'settings.tab.application': '应用',
@@ -115,6 +141,59 @@ export const zhCNCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > App Lock
'settings.appLock.title': '应用锁定',
'settings.appLock.description': '在 Netcatty 打开、长时间无操作或手动锁定后,要求输入本地锁定密码。',
'settings.appLock.setupTitle': '设置锁定密码',
'settings.appLock.setupDescription': '在下方创建密码,保存后会立即启用应用锁定。',
'settings.appLock.manageTitle': '应用锁定已准备就绪',
'settings.appLock.enabledStatus': '已启用。Netcatty 会在启动、长时间无操作或手动锁定时要求输入密码。',
'settings.appLock.disabledStatus': '密码已保存,但应用锁定当前关闭。',
'settings.appLock.disableTitle': '关闭应用锁定',
'settings.appLock.disableDescription': '输入当前锁定密码后停止锁定 Netcatty。已保存的密码会被删除;以后重新启用时需要创建新密码。',
'settings.appLock.disable': '关闭应用锁定',
'settings.appLock.disabling': '正在关闭...',
'settings.appLock.setupPasswordTitle': '创建锁定密码',
'settings.appLock.setupPasswordDescription': '此密码用于解锁 Netcatty,也用于以后修改或关闭应用锁定。',
'settings.appLock.changePasswordTitle': '修改锁定密码',
'settings.appLock.changePasswordDescription': '先输入当前锁定密码,再设置新密码。',
'settings.appLock.enable': '启用应用锁定',
'settings.appLock.enableDesc': '启动时锁定,并在达到所选无操作时间后锁定。',
'settings.appLock.timeout': '无操作超时',
'settings.appLock.timeoutDesc': '无操作时锁定,关闭到托盘时也会锁定。选择“不自动锁定”则两者都不会锁定。',
'settings.appLock.timeout.0': '不自动锁定',
'settings.appLock.timeout.1': '1 分钟',
'settings.appLock.timeout.5': '5 分钟',
'settings.appLock.timeout.15': '15 分钟',
'settings.appLock.timeout.30': '30 分钟',
'settings.appLock.timeout.60': '60 分钟',
'settings.appLock.systemUnlock.label': '{label} 解锁',
'settings.appLock.systemUnlock.desc': '在此设备上使用 {label} 作为解锁 Netcatty 的另一种方式。',
'settings.appLock.systemUnlock.unavailableDesc': '此设备当前无法使用 {label}。',
'settings.appLock.systemUnlock.unavailable': '此设备当前无法使用系统解锁。',
'settings.appLock.systemUnlock.locked': '请先解锁 Netcatty,再更改系统解锁。',
'settings.appLock.systemUnlock.autoPrompt.label': '自动弹出 {label}',
'settings.appLock.systemUnlock.autoPrompt.desc': '当 Netcatty 等待解锁时,自动显示 {label} 验证提示。',
'settings.appLock.currentPassword': '当前密码',
'settings.appLock.currentPasswordPlaceholder': '禁用或替换时需要',
'settings.appLock.currentPasswordForDisablePlaceholder': '用于关闭应用锁定的当前密码',
'settings.appLock.currentPasswordForChangePlaceholder': '用于修改密码的当前密码',
'settings.appLock.newPassword': '新密码',
'settings.appLock.newPasswordPlaceholder': '设置锁定密码',
'settings.appLock.confirmPassword': '确认密码',
'settings.appLock.confirmPasswordPlaceholder': '再次输入密码',
'settings.appLock.savePassword': '设置并启用',
'settings.appLock.savingPassword': '正在保存...',
'settings.appLock.passwordSet': '密码已配置',
'settings.appLock.replacePassword': '替换密码',
'settings.appLock.enableAfterPassword': '请先设置锁定密码,再启用应用锁定。',
'settings.appLock.localOnlyHint': '仅限本设备的隐私锁。不会同步,也不是系统级安全边界。',
'settings.appLock.validation.currentRequired': '请输入当前锁定密码。',
'settings.appLock.validation.newRequired': '请输入新的锁定密码。',
'settings.appLock.validation.confirmRequired': '请确认新的锁定密码。',
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
@@ -813,6 +892,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
'sftp.columns.owner': '所有者',
'sftp.columns.actions': '操作',
'sftp.sort.directoriesFirst': '目录置顶',
'sftp.emptyDirectory': '空目录',
@@ -833,6 +913,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.extract': '解压到当前目录',
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
+13 -2
View File
@@ -30,6 +30,7 @@ export const zhCNTerminalMessages: Messages = {
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.reconnecting': '正在重新连接...',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
@@ -131,7 +132,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
@@ -293,6 +294,10 @@ export const zhCNTerminalMessages: Messages = {
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
'settings.terminal.behavior.autoCloseOnExit': '退出后自动关闭终端',
'settings.terminal.behavior.autoCloseOnExit.desc': '允许终端标签页和窗口在会话退出后自动关闭。关闭此项后,无论退出结果如何都会保留。',
'settings.terminal.behavior.disconnectedNotice': '连接断开时',
'settings.terminal.behavior.disconnectedNotice.desc': '已连接的会话断开后,可在终端内显示简短提示,或继续使用完整弹窗。首次连接失败仍使用弹窗。',
'settings.terminal.behavior.disconnectedNotice.terminal': '终端内提示',
'settings.terminal.behavior.disconnectedNotice.dialog': '弹窗提示',
'settings.terminal.behavior.rightClick.menu': '显示菜单',
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
@@ -338,6 +343,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
'settings.terminal.behavior.oscNotifications.off': '关闭',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
'settings.terminal.behavior.oscNotifications.always': '始终',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -833,7 +844,7 @@ export const zhCNTerminalMessages: Messages = {
'snippets.shortkey.recording': '请按下快捷键组合...',
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
'snippets.shortkey.clear': '清除快捷键',
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
'snippets.shortkey.error.systemConflict': '此快捷键与「{name}」冲突',
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
'snippets.variables.dialogTitle': '填写变量',
+10
View File
@@ -409,6 +409,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.paneMagnification.magnify': '放大当前面板',
'terminal.paneMagnification.restore': '还原面板布局',
'terminal.paneMagnification.hint': '已放大',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.toolbar.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
@@ -908,6 +911,13 @@ export const zhCNVaultMessages: Messages = {
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.folderTitle': '文件夹冲突',
'sftp.conflict.folderDesc': '目标位置已存在同名文件夹',
'sftp.conflict.folderFileDesc': '目标位置已存在同名文件,文件夹无法与文件合并。',
'sftp.conflict.folderSymlinkDesc': '目标位置已存在同名符号链接,文件夹无法与符号链接合并。',
'sftp.conflict.folderUnknownDesc': '目标位置已存在同名项目,但无法确认其类型。为保护目标数据,替换操作不可用。',
'sftp.conflict.folderMergeHint': '合并会保留仅存在于目标文件夹中的文件和子文件夹。',
'sftp.conflict.folderReplaceWarning': '替换会删除目标文件夹中所有未出现在源文件夹里的文件和子文件夹,删除的数据无法恢复。',
'sftp.conflict.alreadyExistsSuffix': '已存在',
'sftp.conflict.existingFile': '已有文件',
'sftp.conflict.newFile': '新文件',
+46 -25
View File
@@ -43,6 +43,10 @@ export const zhTWAiMessages: Messages = {
'ai.providers.style.google': 'Google 相容',
'ai.providers.style.inherited': '預設',
'ai.providers.style.help': '決定請求使用哪種 API 格式。當第三方端點的協定與其供應商型別不一致時,可手動覆蓋。',
'ai.providers.openaiApi': 'OpenAI 請求格式',
'ai.providers.openaiApi.chat': 'Chat Completions',
'ai.providers.openaiApi.responses': 'Responses',
'ai.providers.openaiApi.help': '預設 Chat Completions,相容大多數 OpenAI 相容介面。部分中繼站在 Responses(/v1/responses)上快取命中率較高。',
'ai.providers.icon.change': '修改圖示',
'ai.providers.icon.upload': '上傳圖片',
'ai.providers.icon.reset': '恢復預設',
@@ -54,6 +58,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
'ai.providers.baseUrl.ollamaHelp': '本機 Ollama:http://localhost:11434/v1(不需 API Key)。Ollama Cloud:https://ollama.com/v1,並填寫 Cloud API Key。',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
@@ -85,10 +90,10 @@ export const zhTWAiMessages: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面地址。',
'ai.codex.description': '介接 OpenAI Codex。可以在這裡登入 ChatGPT,也可以在設定裡啟用相容 OpenAI 的 API Key 和自訂介面位址。',
'ai.codex.appServer.title': '使用 Codex App Server',
'ai.codex.appServer.experimental': '實驗性',
'ai.codex.appServer.description': '使用持久化 Codex 協定,提供原生審批、沙箱控制、動態模型和對話中提問。預設仍使用 SDK。',
'ai.codex.appServer.description': '使用持久化 Codex 協定,提供原生核准、沙箱控制、動態模型和對話中提問。預設仍使用 SDK。',
'ai.codex.appServer.checking': '正在檢查 App Server 支援…',
'ai.codex.appServer.available': '目前 Codex CLI 支援 App Server。',
'ai.codex.appServer.modelCatalogWarning': 'Codex 動態模型目錄暫時無法使用,目前將使用內建模型清單。',
@@ -123,7 +128,7 @@ export const zhTWAiMessages: Messages = {
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。請安裝或在下方指定執行檔路徑。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '檢查',
'ai.codex.resetPath': '重置',
'ai.codex.resetPath': '重設',
'ai.codex.openLogin': '開啟登入',
'ai.codex.logout': '登出',
'ai.codex.connectChatGPT': '連線 ChatGPT',
@@ -149,7 +154,7 @@ export const zhTWAiMessages: Messages = {
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一個 KEY=VALUE,傳給 Claude agent。明文存在本機——API key/憑證建議用上面的「設定目錄」(claude 登入),不要放這裡。',
'ai.claude.check': '檢查',
'ai.claude.resetPath': '重置',
'ai.claude.resetPath': '重設',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
@@ -161,7 +166,7 @@ export const zhTWAiMessages: Messages = {
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。請安裝或在下方指定執行檔路徑。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '檢查',
'ai.copilot.resetPath': '重置',
'ai.copilot.resetPath': '重設',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
@@ -210,7 +215,7 @@ export const zhTWAiMessages: Messages = {
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。請安裝或在下方指定執行檔路徑。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '檢查',
'ai.codebuddy.resetPath': '重置',
'ai.codebuddy.resetPath': '重設',
'ai.codebuddy.configSection': '認證與設定(可選)',
'ai.codebuddy.internetEnv': '網路環境',
'ai.codebuddy.internetEnv.default': '預設(海外)',
@@ -266,7 +271,7 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。請安裝或在下方指定執行檔路徑。',
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重置',
'ai.opencode.resetPath': '重設',
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
'ai.grok.title': 'Grok Build',
@@ -278,7 +283,7 @@ export const zhTWAiMessages: Messages = {
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。請安裝 Grok Build CLI 或在下方指定執行檔路徑。',
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
'ai.grok.check': '檢查',
'ai.grok.resetPath': '重置',
'ai.grok.resetPath': '重設',
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
'ai.grok.runtime.acp.default': '預設',
'ai.grok.runtime.acp.description':
@@ -322,7 +327,7 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用審批;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具審批介面。',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
'ai.externalMcp.permissionMode.unknown': '未知',
'ai.externalMcp.discovery': 'Discovery',
'ai.externalMcp.launcher': 'Launcher',
@@ -381,7 +386,7 @@ export const zhTWAiMessages: Messages = {
'ai.quickMessages.title': '快捷訊息',
'ai.quickMessages.description': '建立常用提示詞,在 AI 聊天框輸入 / 或點選快捷按鈕即可插入到輸入框。與使用者 Skills 不同,快捷訊息會直接填入訊息內容。',
'ai.quickMessages.add': '新增快捷訊息',
'ai.quickMessages.createTitle': '新建快捷訊息',
'ai.quickMessages.createTitle': '新增快捷訊息',
'ai.quickMessages.editTitle': '編輯快捷訊息',
'ai.quickMessages.name': '名稱',
'ai.quickMessages.name.placeholder': '例如:檢查磁碟空間',
@@ -403,9 +408,9 @@ export const zhTWAiMessages: Messages = {
// AI Chat
'ai.chat.noProvider': '尚未設定 AI 供應商。請前往 **設定 → AI → 供應商** 新增並啟用一個供應商。',
'ai.chat.toolDenied': '操作已被使用者拒絕。',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApproved': '已核准',
'ai.chat.toolApprovalHint': 'Enter 允許 · Esc 拒絕',
'ai.chat.approve': '批准',
'ai.chat.approve': '核准',
'ai.chat.approveOnce': '允許',
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
@@ -422,7 +427,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.approvalReason': '原因',
'ai.chat.approvalInvocation': '呼叫詳情',
'ai.chat.permissionRequired': '需要權限',
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你核准的工具呼叫。',
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
'ai.chat.recommendAllow': '允許',
'ai.chat.recommendConfirm': '確認',
@@ -441,15 +446,28 @@ export const zhTWAiMessages: Messages = {
'ai.chat.permObserver': '觀察',
'ai.chat.permConfirm': '確認',
'ai.chat.permAuto': '自動',
'ai.chat.permObserverDesc': '只讀(Cursor CLI:Ask)',
'ai.chat.permConfirmDesc': 'Netcatty 寫入前確認(Cursor CLI:agent + force)',
'ai.chat.permAutoDesc': '自由執行(Cursor CLI:agent + force)',
'ai.chat.permObserverDesc': '只讀',
'ai.chat.permConfirmDesc': '寫入前確認',
'ai.chat.permAutoDesc': '自由執行',
'ai.chat.emptyHint': '詢問伺服器相關問題、執行指令或取得設定協助。',
'ai.chat.placeholder': '向 {agent} 傳送訊息 — @ 引用上下文,/ 使用指令',
'ai.chat.placeholderDefault': '向 Catty Agent 傳送訊息...',
'ai.chat.noModel': '未選擇模型',
'ai.chat.noProviderModel': '未設定預設模型——前往 設定 → AI → 供應商 設定。',
'ai.chat.selectProvider': '選擇供應商',
'ai.chat.selectProviderAndModel': '選擇供應商與模型',
'ai.chat.selectModel': '選擇模型',
'ai.chat.searchModels': '搜尋模型',
'ai.chat.providers': '供應商',
'ai.chat.models': '模型',
'ai.chat.pinned': '已釘選',
'ai.chat.useCustomModel': '使用「{id}」',
'ai.chat.thinkingLevel': '思考強度',
'ai.chat.thinkingOff': '關閉',
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
@@ -466,7 +484,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具審批已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '檔案',
@@ -488,7 +506,7 @@ export const zhTWAiMessages: Messages = {
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重啟 Netcatty 或重啟 Electron 開發程序,然後重試。',
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
// AI Web Search
'ai.webSearch.title': '網路搜尋',
@@ -499,7 +517,7 @@ export const zhTWAiMessages: Messages = {
'ai.webSearch.apiKey': 'API 金鑰',
'ai.webSearch.apiKey.description': '所選搜尋供應商的 API 金鑰。',
'ai.webSearch.apiKey.placeholder': '輸入 API 金鑰...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost': 'API 位址',
'ai.webSearch.apiHost.description': '自訂 API 端點。除非使用代理,否則保持預設值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 例項的 URL(必填)。',
'ai.webSearch.maxResults': '最大結果數',
@@ -508,13 +526,16 @@ export const zhTWAiMessages: Messages = {
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '權限模式',
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和審批流程。',
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和核准流程。',
'ai.safety.permissionMode.observer': '觀察者 - 只讀,禁止操作',
'ai.safety.permissionMode.confirm': '確認 - 操作前詢問',
'ai.safety.permissionMode.auto': '自動 - 自由執行',
'ai.safety.commandTimeout': '指令超時',
'ai.safety.commandTimeout.description': '透過 Netcatty 執行指令時允許執行的最長秒數,超時將被終止。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.responseIdleTimeout': '內建 AI 回應等待時間',
'ai.safety.responseIdleTimeout.description': '內建 AI 請求連續這麼多秒沒有收到新回應時會被取消。此設定不控制整段回應總時長,也不影響指令超時。',
'ai.safety.responseIdleTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次數',
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
'ai.safety.blocklist': '指令黑名單',
@@ -525,7 +546,7 @@ export const zhTWAiMessages: Messages = {
'ai.safety.grants.title': '許可記憶表',
'ai.safety.grants.heading': 'Confirm 模式放行規則',
'ai.safety.grants.description': 'Confirm 模式會先詢問再執行;儲存為規則後,相符的同類操作會自動放行。規則對所有終端節點/工作階段生效,也可手動編輯。',
'ai.safety.grants.empty': '尚無規則。可在審批時選擇「一律許可」,或手動新增。',
'ai.safety.grants.empty': '尚無規則。可在核准時選擇「一律許可」,或手動新增。',
'ai.safety.grants.capability': '能力',
'ai.safety.grants.sessionPattern': '節點/工作階段模式',
'ai.safety.grants.commandPattern': '指令模式(可選)',
@@ -560,7 +581,7 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.hostTree.newHostInGroup': '在此群組中新增主機',
'terminal.layer.hostTree.editHost': '編輯主機',
'terminal.layer.hostTree.hostSavedNextConnection': '主機已更新,連線設定將在下次連線時生效。',
'terminal.layer.hostTree.newGroup': '新建分組',
'terminal.layer.hostTree.newGroup': '新增群組',
'terminal.layer.hostTree.localShell': '本機 Shell',
'terminal.layer.hostTree.tagsEmpty': '暫無標籤',
'terminal.layer.hostTree.clearTags': '清除篩選',
@@ -571,11 +592,11 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.hostTree.details.user': '使用者',
'terminal.layer.hostTree.details.port': '埠',
'terminal.layer.hostTree.details.protocol': '協定',
'terminal.layer.hostTree.details.group': '分組',
'terminal.layer.hostTree.details.group': '群組',
'terminal.layer.hostTree.details.tags': '標籤',
'terminal.layer.hostTree.details.lastConnected': '最近連線',
'topTabs.openQuickSwitcher': '開啟快速切換',
'topTabs.moreTabs': '更多標籤頁',
'topTabs.moreTabs': '更多分頁',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.newLocalTerminal': '新增本機終端',
'topTabs.controlPanel': '快捷控制',
@@ -633,5 +654,5 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.overwrite': '覆蓋',
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置為預設',
'settings.shortcuts.resetToDefault': '重設為預設值',
};
+185 -98
View File
@@ -5,7 +5,7 @@ export const zhTWCoreMessages: Messages = {
'common.save': '儲存',
'common.cancel': '取消',
'common.close': '關閉',
'common.reset': '重置',
'common.reset': '重設',
'common.zoomIn': '放大',
'common.zoomOut': '縮小',
'common.settings': '設定',
@@ -41,7 +41,7 @@ export const zhTWCoreMessages: Messages = {
'sort.za': 'Z-a',
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '按分組',
'sort.group': '依群組',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
@@ -49,29 +49,58 @@ export const zhTWCoreMessages: Messages = {
// Dialogs / prompts
'confirm.deleteHost': '刪除主機 "{name}"?',
'confirm.deleteIdentity': '刪除身份 "{name}"?',
'confirm.deleteIdentity': '刪除身分 "{name}"?',
'confirm.removeProvider': '移除供應商 "{name}"?',
'confirm.closeBusyTerminal.title': '確認關閉',
'confirm.closeBusyTerminal.message': '程序 "{command}" 仍在執行,關閉後會被終止。',
'confirm.closeBusyTerminal.messageWithMore': '程序 "{command}" 及其他 {count} 個正在執行的程序將被終止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '關閉',
'dialog.createWorkspace.title': '建立工作區',
'dialog.renameWorkspace.title': '重新命名工作區',
'dialog.renameSession.title': '重新命名工作階段',
'field.name': '名稱',
'field.selectHosts': '選擇主機',
'placeholder.workspaceName': '工作區名稱',
'placeholder.sessionName': '工作階段名稱',
'placeholder.searchHosts': '搜尋主機...',
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
'credentials.protectionUnavailable.title': '憑證保護不可用',
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
'credentials.protectionUnavailable.action': '開啟設定',
// App Lock
'appLock.title': 'Netcatty 已鎖定',
'appLock.reason.default': '請輸入鎖定密碼以繼續。',
'appLock.reason.startup': 'Netcatty 會在每次開啟這個視窗時鎖定。',
'appLock.reason.idle': 'Netcatty 已因閒置逾時而鎖定。',
'appLock.reason.manual': 'Netcatty 已被手動鎖定。',
'appLock.passwordLabel': '鎖定密碼',
'appLock.passwordPlaceholder': '輸入密碼以解鎖',
'appLock.unlock': '解鎖',
'appLock.unlocking': '正在解鎖...',
'appLock.usePassword': '改用密碼解鎖',
'appLock.error.emptyPassword': '請輸入鎖定密碼。',
'appLock.error.incorrectPassword': '鎖定密碼不正確。',
'appLock.systemUnlock.unlockWith': '使用 {label} 解鎖',
'appLock.systemUnlock.preparing': '正在準備 {label}...',
'appLock.systemUnlock.verifying': '正在等待 {label}...',
'appLock.systemUnlock.error': '系統解鎖未完成。請改用鎖定密碼繼續。',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': '重設應用程式鎖定',
'appLock.reset.description': '這會移除目前的鎖定密碼並關閉應用程式鎖定。之後需要再用時,請到設定裡建立新密碼。',
'appLock.reset.cancel': '取消',
'appLock.reset.confirm': '重設應用程式鎖定',
'appLock.reset.resetting': '正在重設...',
'appLock.reset.error': '無法重設應用程式鎖定,請重試。',
'topTabs.lockApp': '鎖定 Netcatty',
// Settings shell
'settings.title': '設定',
'settings.tab.application': '應用程式',
'settings.tab.appearance': '外觀',
'settings.tab.terminal': '終端',
'settings.tab.shortcuts': '快捷鍵',
'settings.tab.shortcuts': '快速鍵',
'settings.tab.ai': 'AI',
'settings.tab.syncCloud': '同步與雲',
'settings.tab.system': '系統',
@@ -115,6 +144,59 @@ export const zhTWCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他使用者或機器上加密的憑證無法在此處解密。請在目前裝置重新輸入並儲存憑證。',
'settings.system.credentials.portabilityHint': '雲同步可跨裝置,因為使用主金鑰加密;本機 safeStorage 加密僅綁定目前系統使用者/裝置。',
// Settings > System > App Lock
'settings.appLock.title': '應用程式鎖定',
'settings.appLock.description': '在 Netcatty 開啟、閒置一段時間或手動鎖定後,要求輸入本機的鎖定密碼。',
'settings.appLock.setupTitle': '設定鎖定密碼',
'settings.appLock.setupDescription': '請在下方建立密碼,儲存後會立即啟用應用程式鎖定。',
'settings.appLock.manageTitle': '應用程式鎖定已就緒',
'settings.appLock.enabledStatus': '已啟用。Netcatty 會在啟動、閒置逾時以及手動鎖定時要求輸入密碼。',
'settings.appLock.disabledStatus': '密碼已儲存,但應用程式鎖定目前為關閉。',
'settings.appLock.disableTitle': '關閉應用程式鎖定',
'settings.appLock.disableDescription': '輸入目前的鎖定密碼即可停止鎖定 Netcatty。已儲存的密碼會一併移除;日後要再啟用必須重新建立新密碼。',
'settings.appLock.disable': '關閉應用程式鎖定',
'settings.appLock.disabling': '正在關閉...',
'settings.appLock.setupPasswordTitle': '建立鎖定密碼',
'settings.appLock.setupPasswordDescription': '這組密碼用來解鎖 Netcatty,日後修改或關閉應用程式鎖定時也會用到。',
'settings.appLock.changePasswordTitle': '修改鎖定密碼',
'settings.appLock.changePasswordDescription': '先輸入目前的鎖定密碼,再設定新密碼。',
'settings.appLock.enable': '啟用應用程式鎖定',
'settings.appLock.enableDesc': '啟動時鎖定,並在達到所選的閒置逾時後鎖定。',
'settings.appLock.timeout': '閒置逾時',
'settings.appLock.timeoutDesc': '閒置時鎖定,關閉到系統匣時也會鎖定。選擇「不自動鎖定」則兩者都不會鎖定。',
'settings.appLock.timeout.0': '不自動鎖定',
'settings.appLock.timeout.1': '1 分鐘',
'settings.appLock.timeout.5': '5 分鐘',
'settings.appLock.timeout.15': '15 分鐘',
'settings.appLock.timeout.30': '30 分鐘',
'settings.appLock.timeout.60': '60 分鐘',
'settings.appLock.systemUnlock.label': '{label} 解鎖',
'settings.appLock.systemUnlock.desc': '在這台裝置上,使用 {label} 作為解鎖 Netcatty 的另一種方式。',
'settings.appLock.systemUnlock.unavailableDesc': '這台裝置目前無法使用 {label}。',
'settings.appLock.systemUnlock.unavailable': '這台裝置目前無法使用系統解鎖。',
'settings.appLock.systemUnlock.locked': '請先解鎖 Netcatty,再變更系統解鎖設定。',
'settings.appLock.systemUnlock.autoPrompt.label': '自動開啟 {label}',
'settings.appLock.systemUnlock.autoPrompt.desc': '當 Netcatty 等待解鎖時,自動顯示 {label} 驗證提示。',
'settings.appLock.currentPassword': '目前密碼',
'settings.appLock.currentPasswordPlaceholder': '停用或更換時需要',
'settings.appLock.currentPasswordForDisablePlaceholder': '用於關閉應用程式鎖定的目前密碼',
'settings.appLock.currentPasswordForChangePlaceholder': '用於修改密碼的目前密碼',
'settings.appLock.newPassword': '新密碼',
'settings.appLock.newPasswordPlaceholder': '設定鎖定密碼',
'settings.appLock.confirmPassword': '確認密碼',
'settings.appLock.confirmPasswordPlaceholder': '再次輸入密碼',
'settings.appLock.savePassword': '設定並啟用',
'settings.appLock.savingPassword': '正在儲存...',
'settings.appLock.passwordSet': '密碼已設定',
'settings.appLock.replacePassword': '更換密碼',
'settings.appLock.enableAfterPassword': '請先設定鎖定密碼,再啟用應用程式鎖定。',
'settings.appLock.localOnlyHint': '僅限本裝置的隱私鎖。不會同步,也不是作業系統層級的安全邊界。',
'settings.appLock.validation.currentRequired': '請輸入目前的鎖定密碼。',
'settings.appLock.validation.newRequired': '請輸入新的鎖定密碼。',
'settings.appLock.validation.confirmRequired': '請確認新的鎖定密碼。',
'settings.appLock.validation.mismatch': '兩次輸入的新密碼不一致。',
'settings.appLock.validation.incorrect': '目前的鎖定密碼不正確。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '網路代理',
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
@@ -155,7 +237,7 @@ export const zhTWCoreMessages: Messages = {
'settings.update.download': '下載更新',
'settings.update.downloading': '正在下載... {percent}%',
'settings.update.readyToInstall': '更新已下載,準備安裝。',
'settings.update.restartNow': '重啟並更新',
'settings.update.restartNow': '重新啟動並更新',
'settings.update.error': '檢查更新失敗。',
'settings.update.downloadError': '下載失敗。',
'settings.update.manualDownload': '前往 GitHub 下載',
@@ -174,12 +256,12 @@ export const zhTWCoreMessages: Messages = {
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
'settings.sessionRestore.restorePreviousSession': '恢復上次的分頁和工作區',
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端分頁和工作區佈局。',
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
'settings.sessionRestore.restoreTerminalCwdDesc': '重新連線恢復的終端時,嘗試回到上次記錄的目錄;失敗時不會阻塞連線。',
'terminal.restore.placeholder.title': '已恢復的工作階段',
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個標籤頁啟動新的 shell。',
'terminal.restore.placeholder.desc': '此終端目前已斷開。準備好後可以重新連線,為這個分頁啟動新的 shell。',
'terminal.restore.placeholder.reconnect': '重新連線',
'terminal.restore.cwdLog': '正在恢復工作目錄:{cwd}',
@@ -218,16 +300,16 @@ export const zhTWCoreMessages: Messages = {
'settings.sshDebugLogs.hint': '開啟後,新發起的 SSH 連線會寫入診斷資訊,方便排查堡壘機、認證和異常斷開問題。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全域快捷鍵',
'settings.globalHotkey.title': '全域快速鍵',
'settings.globalHotkey.toggleWindow': '切換視窗',
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快捷鍵。',
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
'settings.globalHotkey.notSet': '未設定',
'settings.globalHotkey.reset': '恢復預設',
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
'settings.globalHotkey.enabled': '啟用全域快捷鍵',
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快捷鍵。停用後將取消所有全域快捷鍵註冊。',
'settings.globalHotkey.hint': '全域快捷鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
'settings.globalHotkey.enabled': '啟用全域快速鍵',
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
// Tray Panel
'tray.openMainWindow': '開啟主視窗',
@@ -254,7 +336,7 @@ export const zhTWCoreMessages: Messages = {
// Settings > Application
'settings.application.checkUpdates': '檢查更新',
'settings.application.reportProblem': '反饋問題',
'settings.application.reportProblem.subtitle': '生成預填的 GitHub issue',
'settings.application.reportProblem.subtitle': '產生預填的 GitHub issue',
'settings.application.community': '社群',
'settings.application.community.subtitle': 'GitHub Discussions',
'settings.application.github': 'GitHub',
@@ -267,13 +349,13 @@ export const zhTWCoreMessages: Messages = {
'settings.vault.showRecentHosts': '顯示最近連線的主機',
'settings.vault.showRecentHostsDesc': '在主機列表頂部顯示最近連線過的主機',
'settings.vault.selectBeforeConnect': '先選取再連線',
'settings.vault.selectBeforeConnectDesc': '單擊選取主機(網格模式顯示強調色邊框),再次單擊連線;分組同理。預設仍為單擊直接連線。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未分組主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有分組的主機,已分組主機請從左側分組進入檢視。',
'settings.vault.showSftpTab': '顯示 SFTP 標籤頁',
'settings.vault.showSftpTabDesc': '在頂部標籤欄顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器標籤頁顯示主機列表側欄及頂部開關。',
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
'settings.vault.autoImportSystemKnownHostsDesc': '開啟主機庫時靜默匯入 OpenSSH known_hosts 中的記錄。關閉後,Netcatty 已知主機與系統信任庫保持獨立;仍可透過「掃描系統」手動匯入。',
@@ -288,11 +370,11 @@ export const zhTWCoreMessages: Messages = {
'update.viewInSettings': '在設定中檢視',
'update.readyToInstall.title': '更新已就緒',
'update.readyToInstall.message': '版本 {version} 已下載完成,準備安裝。',
'update.restartNow': '立即重啟',
'update.restartNow': '立即重新啟動',
'update.downloadFailed.title': '更新失敗',
'update.downloadFailed.message': '下載更新失敗,可前往 GitHub 手動下載。',
'update.needsSave.title': '有未儲存內容',
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重啟」以安裝更新。',
'update.needsSave.message': '請先儲存已開啟的編輯器,然後再次點選「立即重新啟動」以安裝更新。',
'update.openReleases': '開啟 Releases',
'update.remindLater': '稍後提醒',
'update.skipVersion': '跳過此版本',
@@ -331,11 +413,11 @@ export const zhTWCoreMessages: Messages = {
}
\`\`\`
部分列 / 窗格還支援 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等狀態鉤子。
部分列 / 窗格還支援 \`[data-selected="true"]\`、\`[data-focused="true"]\` 等狀態掛鉤。
由於 Tailwind 工具類優先順序較高,覆蓋樣式時通常需要使用 \`!important\`。
### 頂部標籤列
### 頂部分頁列
- \`top-tabs\`
- \`top-tabs-toolbar-actions\` — 右側選單和工具列
@@ -406,7 +488,7 @@ export const zhTWCoreMessages: Messages = {
}
\`\`\`
隱藏頂部標籤列裡的主機列表開關:
隱藏頂部分頁列裡的主機列表開關:
\`\`\`css
[data-section="top-tabs-host-tree-toggle"] {
@@ -435,7 +517,7 @@ export const zhTWCoreMessages: Messages = {
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部標籤欄裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部標籤 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型',
@@ -443,7 +525,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。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
'settings.appearance.appIcon.bright': '明亮',
'settings.appearance.appIcon.dark': '深色',
@@ -460,8 +542,8 @@ export const zhTWCoreMessages: Messages = {
'settings.appearance.appIcon.whiteViolet': '白底 · 紫羅蘭貓',
'settings.appearance.appIcon.rainbow': '彩虹貓',
// Context menus / common actions
'action.newHost': '新建主機',
'action.newSubfolder': '新建資料夾',
'action.newHost': '新增主機',
'action.newSubfolder': '新增資料夾',
'action.copyPublicKey': '複製公鑰',
'action.keyExport': '匯出金鑰',
'action.edit': '編輯',
@@ -504,7 +586,7 @@ export const zhTWCoreMessages: Messages = {
'sync.autoSync.keptLocalTitle': '已保留本機資料',
'sync.autoSync.keptLocalMessage': '保留了空的本機主機庫,未套用雲端資料。',
'sync.autoSync.emptyVaultConflict.title': '偵測到空主機庫',
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但云端有資料。這通常發生在應用程式更新或儲存重置之後。請選擇如何處理:',
'sync.autoSync.emptyVaultConflict.description': '本機主機庫為空,但雲端有資料。這通常發生在應用程式更新或儲存重設之後。請選擇如何處理:',
'sync.autoSync.emptyVaultConflict.cloudLabel': '雲端',
'sync.autoSync.emptyVaultConflict.restore': '從雲端恢復',
'sync.autoSync.emptyVaultConflict.restoreDesc': '推薦 — 從雲端備份恢復主機、金鑰和程式碼片段',
@@ -527,14 +609,14 @@ export const zhTWCoreMessages: Messages = {
'sync.entityType.hosts': '主機',
'sync.entityType.keys': '金鑰',
'sync.entityType.identities': '身份',
'sync.entityType.identities': '身分',
'sync.entityType.proxyProfiles': '代理設定',
'sync.entityType.snippets': '程式碼片段',
'sync.entityType.customGroups': '分組',
'sync.entityType.customGroups': '群組',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主機金鑰記錄',
'sync.entityType.portForwardingRules': '埠轉發規則',
'sync.entityType.groupConfigs': '分組設定',
'sync.entityType.groupConfigs': '群組設定',
'sync.credentialsUnavailable': '目前裝置無法解密部分已儲存憑證。請先在本機重新輸入憑證後再同步。',
'time.never': '從未',
@@ -559,58 +641,58 @@ export const zhTWCoreMessages: Messages = {
'proxyProfiles.empty.desc': '建立可重複使用的 HTTP、SOCKS5 或指令代理,然後在主機詳情裡選擇。',
'proxyProfiles.usage': '已關聯 {count} 處',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.panel.newTitle': '新增代理',
'proxyProfiles.field.name': '代理名稱',
'proxyProfiles.error.required': '名稱和代理詳情不能為空。',
'proxyProfiles.error.port': '埠必須在 1 到 65535 之間。',
'proxyProfiles.viewMode': '代理顯示方式',
'proxyProfiles.delete.title': '刪除代理?',
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或分組設定中解除關聯。',
'proxyProfiles.delete.desc': '刪除 "{name}" 會同時從 {count} 個主機或群組設定中解除關聯。',
'vault.groups.title': '分組',
'vault.groups.title': '群組',
'vault.groups.total': '共 {count} 個',
'vault.groups.hostsCount': '{count} 台主機',
'vault.groups.newSubgroup': '新建子分組',
'vault.groups.rename': '重新命名分組',
'vault.groups.unnamed': '未命名分組',
'vault.groups.delete': '刪除分組',
'vault.groups.createSubfolder': '建立子分組',
'vault.groups.createRoot': '建立根分組',
'vault.groups.createDialog.desc': '建立新的分組用於組織主機。',
'vault.groups.renameDialogTitle': '重新命名分組',
'vault.groups.renameDialog.desc': '重新命名已有分組。',
'vault.groups.deleteDialogTitle': '刪除分組',
'vault.groups.deleteDialog.desc': '這將永久刪除該分組並將所有主機移動到根級別。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 分組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該分組下的所有主機',
'vault.groups.deleteDialog.bulkTitle': '刪除所選分組',
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選分組,並把其中的主機移動到根層級。',
'vault.groups.deleteDialog.bulkDeleteHosts': '同時刪除所選分組下的所有主機',
'vault.groups.selectedCount': '已選擇 {count} 個分組',
'vault.groups.newSubgroup': '新增子群組',
'vault.groups.rename': '重新命名群組',
'vault.groups.unnamed': '未命名群組',
'vault.groups.delete': '刪除群組',
'vault.groups.createSubfolder': '建立子群組',
'vault.groups.createRoot': '建立根群組',
'vault.groups.createDialog.desc': '建立新的群組用於組織主機。',
'vault.groups.renameDialogTitle': '重新命名群組',
'vault.groups.renameDialog.desc': '重新命名已有群組。',
'vault.groups.deleteDialogTitle': '刪除群組',
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
'vault.groups.deleteDialog.bulkDeleteHosts': '同時刪除所選群組下的所有主機',
'vault.groups.selectedCount': '已選擇 {count} 個群組',
'vault.groups.deleteMultiple.success': '已刪除 {count} 個所選項目',
'vault.groups.ungrouped': '未分組',
'vault.groups.field.name': '分組名稱',
'vault.groups.ungrouped': '未群組',
'vault.groups.field.name': '群組名稱',
'vault.groups.placeholder.example': '例如:Production',
'vault.groups.parentLabel': '父級',
'vault.groups.pathLabel': '路徑',
'vault.groups.settings': '分組設定',
'vault.groups.details': '分組詳情',
'vault.groups.settings': '群組設定',
'vault.groups.details': '群組詳情',
'vault.groups.details.general': '常規',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高階',
'vault.groups.details.appearance': '外觀',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分組',
'vault.groups.details.parentGroup': '父群組',
'vault.groups.details.none': '無',
'vault.groups.details.inherited': '繼承自分組',
'vault.groups.details.inherited': '繼承自群組',
'vault.groups.details.addProtocol': '新增協定',
'vault.groups.details.removeProtocol': '移除協定',
'vault.groups.details.fontFamily': '字型',
'vault.groups.details.fontSize': '字號',
'vault.groups.errors.required': '分組名稱不能為空。',
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
'vault.groups.details.fontSize': '字型大小',
'vault.groups.errors.required': '群組名稱不能為空。',
'vault.groups.errors.invalidChars': "群組名稱不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '該位置已存在同名群組。',
'vault.deleteConfirm.title': '刪除「{name}」?',
'vault.deleteConfirm.desc': '此操作不可復原。',
@@ -619,7 +701,7 @@ export const zhTWCoreMessages: Messages = {
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
'vault.managedSource.unmanage': '取消託管',
'vault.managedSource.unmanageSuccess': '已取消託管分組',
'vault.managedSource.unmanageSuccess': '已取消託管群組',
'vault.hosts.header.entries': '{count} 條',
'vault.hosts.header.live': '{count} 個線上',
@@ -632,8 +714,8 @@ export const zhTWCoreMessages: Messages = {
'vault.view.tree': '樹形',
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新建主機',
'vault.hosts.newGroup': '新建分組',
'vault.hosts.newHost': '新增主機',
'vault.hosts.newGroup': '新增群組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
@@ -645,12 +727,12 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.recentlyConnected': '最近連線',
'vault.hosts.pinToTop': '置頂',
'vault.hosts.unpin': '取消置頂',
'vault.hosts.copyCredentials': '複製賬密資訊',
'vault.hosts.copyCredentials.toast.success': '賬密資訊已複製到剪貼簿',
'vault.hosts.copyCredentials': '複製帳密資訊',
'vault.hosts.copyCredentials.toast.success': '帳密資訊已複製到剪貼簿',
'vault.hosts.copyCredentials.toast.noPassword': '該主機未儲存密碼',
'vault.hosts.multiSelect': '多選',
'vault.hosts.selected': '已選擇 {count} 項',
'vault.hosts.selectedSummary': '已選擇 {hosts} 個主機和 {groups} 個分組',
'vault.hosts.selectedSummary': '已選擇 {hosts} 個主機和 {groups} 個群組',
'vault.hosts.selectAll': '全選',
'vault.hosts.deselectAll': '取消全選',
'vault.hosts.deleteSelected': '刪除 ({count})',
@@ -668,16 +750,16 @@ export const zhTWCoreMessages: Messages = {
// Vault import
'vault.import.title': '新增資料到你的 Vault',
'vault.import.desc': '從常見工具遷移連線資訊。選擇一種格式開始匯入。',
'vault.import.desc': '從常見工具移轉連線資訊。選擇一種格式開始匯入。',
'vault.import.chooseFormat': '選擇檔案格式',
'vault.import.destination.title': '匯入的主機要放到哪裡?',
'vault.import.destination.preserve': '保留原始分組',
'vault.import.destination.existing': '選擇現有分組',
'vault.import.destination.new': '新增分組',
'vault.import.destination.noGroups': '暫無可選分組',
'vault.import.destination.newPlaceholder': '輸入新分組名稱',
'vault.import.destination.preserve': '保留原始群組',
'vault.import.destination.existing': '選擇現有群組',
'vault.import.destination.new': '新增群組',
'vault.import.destination.noGroups': '暫無可選群組',
'vault.import.destination.newPlaceholder': '輸入新群組名稱',
'vault.import.destination.settings': '匯入位置',
'vault.import.destination.settingsHint': '設定主機要放到哪個分組',
'vault.import.destination.settingsHint': '設定主機要放到哪個群組',
'vault.import.destination.done': '完成',
'vault.import.securecrt.directoryHint': '請選擇 SecureCRT 的 Sessions 資料夾。',
'vault.import.securecrt.chooseSource': '選擇 SecureCRT 匯入來源。',
@@ -725,11 +807,11 @@ export const zhTWCoreMessages: Messages = {
'vault.import.sshConfig.importOnlyDesc': '一次性匯入,修改不會同步回檔案。',
'vault.import.sshConfig.managed': '託管同步',
'vault.import.sshConfig.managedDesc': '保持同步,修改會自動儲存回檔案。',
'vault.import.sshConfig.managedDestinationHint': '託管同步會使用獨立的託管分組;上方目標分組只用於一次性匯入。',
'vault.import.sshConfig.managedDestinationHint': '託管同步會使用獨立的託管群組;上方目標群組只用於一次性匯入。',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': '已匯入 {count} 個主機,檔案已託管。',
'vault.import.sshConfig.alreadyManaged': '該檔案已被託管。',
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在分組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
'vault.import.sshConfig.alreadyManagedDesc': '該檔案已在群組 "{group}" 下託管。如需重新匯入,請先移除現有的託管源。',
'vault.import.sshConfig.noFilePath': '無法託管此檔案。',
'vault.import.sshConfig.noFilePathDesc': '無法確定檔案路徑。託管同步需要存取檔案系統。',
@@ -755,7 +837,7 @@ export const zhTWCoreMessages: Messages = {
'pf.title': '埠轉發規則',
'pf.rulesCount': '{count} 條規則',
'pf.wizard.editTitle': '編輯埠轉發規則',
'pf.wizard.newTitle': '新建埠轉發規則',
'pf.wizard.newTitle': '新增埠轉發規則',
'pf.wizard.saveChanges': '儲存修改',
'pf.wizard.done': '完成',
'pf.wizard.continue': '繼續',
@@ -773,24 +855,24 @@ export const zhTWCoreMessages: Messages = {
'pf.type.remote.desc': '遠端轉發在遠端開啟埠,並將連線轉發到本機(目前)主機。',
'pf.type.dynamic.desc': '動態轉發將 Netcatty 作為 SOCKS 代理使用。',
'pf.wizard.type.title': '選擇埠轉發型別:',
'pf.wizard.localConfig.title': '設定本機埠與繫結地址:',
'pf.wizard.localConfig.title': '設定本機埠與繫結位址:',
'pf.wizard.localConfig.desc': '該埠會在本機(目前裝置)開啟,並接收流量。',
'pf.wizard.localConfig.localPort': '本機埠 *',
'pf.wizard.bindAddress': '繫結地址',
'pf.wizard.bindAddress': '繫結位址',
'pf.wizard.remoteHost.title': '選擇遠端主機:',
'pf.wizard.remoteHost.desc': '選擇要開啟埠的遠端主機。該埠的流量將轉發到目標地址。',
'pf.wizard.remoteConfig.title': '設定埠與繫結地址:',
'pf.wizard.remoteConfig.desc': '將從所選主機的指定埠與網路卡地址轉發流量。',
'pf.wizard.remoteHost.desc': '選擇要開啟埠的遠端主機。該埠的流量將轉發到目標位址。',
'pf.wizard.remoteConfig.title': '設定埠與繫結位址:',
'pf.wizard.remoteConfig.desc': '將從所選主機的指定埠與網路卡位址轉發流量。',
'pf.wizard.remoteConfig.remotePort': '遠端埠 *',
'pf.wizard.destination.title': '設定目標地址:',
'pf.wizard.destination.desc.local': '輸入你希望透過 tunnel 存取的遠端目標地址。',
'pf.wizard.destination.desc.remote': '要轉發流量到的目標地址與埠。',
'pf.wizard.destination.address': '目標地址 *',
'pf.wizard.destination.title': '設定目標位址:',
'pf.wizard.destination.desc.local': '輸入你希望透過 tunnel 存取的遠端目標位址。',
'pf.wizard.destination.desc.remote': '要轉發流量到的目標位址與埠。',
'pf.wizard.destination.address': '目標位址 *',
'pf.wizard.destination.addressPlaceholder': '例如:127.0.0.1 或 192.168.1.100',
'pf.wizard.destination.port': '目標埠 *',
'pf.wizard.sshServer.title': '選擇 SSH server:',
'pf.wizard.sshServer.desc.dynamic': '選擇作為 SOCKS proxy 的 SSH server。',
'pf.wizard.sshServer.desc.default': '選擇用於將流量 tunnel 到目標地址的 SSH server。',
'pf.wizard.sshServer.desc.default': '選擇用於將流量 tunnel 到目標位址的 SSH server。',
'pf.wizard.label.title': '設定 Label:',
'pf.wizard.label.placeholder.dynamic': '例如:SOCKS Proxy',
'pf.wizard.label.placeholder.default': '例如:MySQL Production',
@@ -798,8 +880,8 @@ export const zhTWCoreMessages: Messages = {
'pf.wizard.placeholders.portExample': '例如:{port}',
// SFTP
'sftp.newFolder': '新建資料夾',
'sftp.newFile': '新建檔案',
'sftp.newFolder': '新增資料夾',
'sftp.newFile': '新增檔案',
'sftp.filter': '篩選',
'sftp.filter.placeholder': '按檔名篩選...',
'sftp.bookmark.add': '收藏此路徑',
@@ -813,6 +895,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.columns.modified': '修改時間',
'sftp.columns.size': '大小',
'sftp.columns.kind': '型別',
'sftp.columns.owner': '擁有者',
'sftp.columns.actions': '操作',
'sftp.sort.directoriesFirst': '資料夾置頂',
'sftp.emptyDirectory': '空目錄',
@@ -822,7 +905,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.upload': '上傳',
'sftp.uploadFiles': '上傳檔案',
'sftp.uploadFolder': '上傳資料夾',
'sftp.dragDropToUpload': '拖拽檔案到這裡上傳',
'sftp.dragDropToUpload': '拖曳檔案到這裡上傳',
'sftp.retry': '重試',
'sftp.context.open': '開啟',
'sftp.context.navigateTo': '跳轉到這裡',
@@ -833,6 +916,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.moveTo.confirm': '移動',
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.extract': '解壓到目前目錄',
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
@@ -856,7 +943,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.context.uploadFolderHere': '上傳資料夾到這裡...',
'sftp.context.downloadSelected': '下載選取項({count})',
'sftp.context.deleteSelected': '刪除選取項({count})',
'sftp.dropFilesHere': '拖拽檔案到這裡',
'sftp.dropFilesHere': '拖曳檔案到這裡',
'sftp.itemsCount': '{count} 個專案',
'sftp.selectedCount': '已選 {count} 個',
'sftp.path.doubleClickToEdit': '雙擊編輯路徑',
@@ -894,7 +981,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transferCenter.phase.uploading': '正在上傳',
'sftp.transferCenter.phase.transferring': '傳輸中',
'sftp.transferCenter.phase.extracting': '正在解壓',
'sftp.transferCenter.phase.verifying': '正在校驗',
'sftp.transferCenter.phase.verifying': '正在驗證',
'sftp.transferCenter.bucket.active': '進行中',
'sftp.transferCenter.bucket.all': '全部',
'sftp.transferCenter.bucket.queued': '排隊中',
@@ -925,7 +1012,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transferCenter.clear': '清除記錄',
'sftp.transferCenter.applyAll': '套用到全部',
'sftp.transfers.resizeNameColumn': '調整檔名列寬',
'sftp.transfers.dragToResize': '拖拽調整高度',
'sftp.transfers.dragToResize': '拖曳調整高度',
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
@@ -941,7 +1028,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔名稱',
'sftp.fileName.placeholder': '輸入檔名稱',
'sftp.prompt.newFolderName': '新建資料夾名稱?',
'sftp.prompt.newFolderName': '新增資料夾名稱?',
'sftp.rename.title': '重新命名',
'sftp.rename.newName': '新名稱',
'sftp.rename.placeholder': '輸入新名稱',
@@ -975,7 +1062,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.picker.connected.connecting': '連線中',
'sftp.picker.noMatch': '沒有相符的主機',
'sftp.permissions.title': '編輯權限',
'sftp.permissions.owner': '所有者',
'sftp.permissions.owner': '擁有者',
'sftp.permissions.group': '群組',
'sftp.permissions.others': '其他',
'sftp.permissions.octal': '八進位制',
@@ -984,7 +1071,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.permissions.failed': '權限更新失敗',
// Quick Switcher
'qs.search.placeholder': '搜尋主機或標籤頁',
'qs.search.placeholder': '搜尋主機或分頁',
'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell',
+12 -12
View File
@@ -18,21 +18,21 @@ export const zhTWScriptsMessages = {
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
'scripts.targets.hint': '所選群組會動態比對,之後加入群組的主機也會自動生效。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
'hostDetails.automation.groupScripts': '分組繼承指令碼',
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
'hostDetails.automation.groupScripts': '群組繼承指令碼',
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在群組,並在主機專屬佇列之前執行。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
'scripts.actions.runParallel': '在所有標籤頁並行執行',
'scripts.actions.runSequential': '在所有標籤頁依序執行',
'scripts.actions.runOnAllTabs': '在所有標籤頁執行',
'scripts.actions.skippedConnectingSessions': '有 {count} 個標籤仍在連線中,已跳過',
'scripts.actions.skippedSensitiveSessions': '有 {count} 個標籤處於密碼/敏感輸入,已跳過',
'scripts.actions.runParallel': '在所有分頁並行執行',
'scripts.actions.runSequential': '在所有分頁依序執行',
'scripts.actions.runOnAllTabs': '在所有分頁執行',
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
'scripts.sidePanel.library': '指令碼庫',
'scripts.sidePanel.running': '執行中',
'scripts.sidePanel.newScript': '新建指令碼',
'scripts.sidePanel.newScript': '新增指令碼',
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
'scripts.running.unnamed': '未命名指令碼',
'scripts.running.status.running': '執行中',
@@ -49,7 +49,7 @@ export const zhTWScriptsMessages = {
'scripts.recording.unavailableHint': '此入口不支援錄製,請用終端右側的指令碼側欄',
'scripts.recording.activeHint': '正在錄製目前終端。在終端中正常輸入指令;點「停止錄製」或終端工具列的 REC 結束並儲存。',
'scripts.recording.started': '已開始錄製,請在終端中操作',
'scripts.recording.noSession': '請先連線終端(單一標籤或工作區皆可)',
'scripts.recording.noSession': '請先連線終端(單一分頁或工作區皆可)',
'scripts.recording.alreadyActive': '已有其他終端正在錄製',
'scripts.recording.stop': '停止錄製',
'scripts.recording.pause': '暫停錄製',
@@ -62,7 +62,7 @@ export const zhTWScriptsMessages = {
'scripts.recording.saveAndEdit': '儲存並編輯',
'scripts.recording.helpTitle': '如何錄製指令碼',
'scripts.recording.helpIntro': '錄製會把你在終端裡的操作自動轉成可重複執行的自動化指令碼,適合部署、巡檢等重複性任務。',
'scripts.recording.helpStep1': '連線主機。單一終端標籤或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
'scripts.recording.helpStep1': '連線主機。單一終端分頁或工作區裡的終端都可以,開啟右側的指令碼側欄即可。',
'scripts.recording.helpStep2': '點選「開始錄製」。終端工具列出現紅色 REC 表示已開始。',
'scripts.recording.helpStep3': '在終端裡像平常一樣輸入指令,按 Enter 執行。每次按 Enter 都會記為一步。',
'scripts.recording.helpStep4': '全部操作完成後,點選「停止錄製」,或點終端工具列 REC 旁的停止按鈕。',
@@ -106,7 +106,7 @@ export const zhTWScriptsMessages = {
'scripts.observer.blocked': 'Observer 模式禁止執行會寫入終端的指令碼。',
'vault.section.scripts': '指令碼',
'vault.nav.scripts': '指令碼',
'snippets.action.newScript': '新建自動化指令碼',
'snippets.action.newScript': '新增自動化指令碼',
'hostDetails.section.automation': '自動化',
'hostDetails.automation.loginScript': '登入指令碼',
'hostDetails.automation.loginScriptPlaceholder': '選擇指令碼',
@@ -52,10 +52,10 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體占用最高程序',
'systemManager.overview.topProcesses': '記憶體佔用最高程序',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網卡資料',
'systemManager.overview.noInterfaces': '暫無網路卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.processes.search': '搜尋程序…',
@@ -97,9 +97,9 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.common.loadingDetails': '正在載入詳情…',
'systemManager.common.loadingStats': '正在載入效能資料…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.new': '新增',
'systemManager.tmux.search': '搜尋工作階段…',
'systemManager.tmux.newSessionTitle': '新建 tmux 工作階段',
'systemManager.tmux.newSessionTitle': '新增 tmux 工作階段',
'systemManager.tmux.newSessionDesc': '為工作階段命名,並可選在啟動時執行指令碼。',
'systemManager.tmux.newSessionTabCustom': '自訂指令',
'systemManager.tmux.newSessionTabSnippet': '從程式碼片段',
@@ -127,7 +127,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tmux.loadingDetails': '正在載入詳情…',
'systemManager.tmux.clients': '已附加用戶端',
'systemManager.tmux.windowList': '視窗',
'systemManager.tmux.newWindow': '新建視窗',
'systemManager.tmux.newWindow': '新增視窗',
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
'systemManager.tmux.noWindows': '沒有視窗',
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
+113 -102
View File
@@ -14,7 +14,7 @@ export const zhTWTerminalMessages: Messages = {
'terminal.clipboardImageUpload.noImage': '剪貼簿中沒有圖片',
'terminal.clipboardImageUpload.failed': '無法上傳剪貼簿圖片',
'terminal.toolbar.configureOsc7': '設定目錄追蹤',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.toolbar.detach': '移出到獨立分頁',
'terminal.toolbar.dragPane': '拖動終端窗格',
'terminal.toolbar.showActions': '顯示終端操作',
'terminal.menu.detach': '從工作區移出',
@@ -30,6 +30,7 @@ export const zhTWTerminalMessages: Messages = {
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
'terminal.progress.reconnecting': '正在重新連線...',
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
@@ -86,7 +87,7 @@ export const zhTWTerminalMessages: Messages = {
'sftp.preview.title': '圖片預覽',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '縮小',
'sftp.preview.resetZoom': '重置縮放',
'sftp.preview.resetZoom': '重設縮放',
'sftp.preview.fitToWindow': '適應視窗',
// Settings > SFTP File Associations
@@ -131,7 +132,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
@@ -157,7 +158,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.followTerminalCwd.enableDesc': '開啟側欄 SFTP 時預設啟用追隨模式,終端執行 cd 後檔案瀏覽器會自動跳轉',
'settings.sftp.defaultViewMode': '預設檢視模式',
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 標籤頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
'settings.sftp.defaultViewMode.desc': '選擇開啟新 SFTP 分頁時的預設檢視模式。每個主機的偏好設定會覆蓋此全域設定。',
'settings.sftp.defaultViewMode.list': '列表檢視',
'settings.sftp.defaultViewMode.listDesc': '以平面列表顯示目前目錄的檔案',
'settings.sftp.defaultViewMode.tree': '樹形檢視',
@@ -204,7 +205,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.compressedUpload': '資料夾壓縮傳輸',
'settings.sftp.compressedUpload.desc': '上傳前壓縮資料夾,可大幅減少傳輸時間。',
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動改用一般傳輸。',
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
@@ -292,7 +293,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
'settings.terminal.behavior.autoCloseOnExit': '結束後自動關閉終端',
'settings.terminal.behavior.autoCloseOnExit.desc': '允許終端標籤頁和視窗在工作階段結束後自動關閉。關閉此項後,無論結束結果如何都會保留。',
'settings.terminal.behavior.autoCloseOnExit.desc': '允許終端分頁和視窗在工作階段結束後自動關閉。關閉此項後,無論結束結果如何都會保留。',
'settings.terminal.behavior.disconnectedNotice': '連線中斷時',
'settings.terminal.behavior.disconnectedNotice.desc': '已連線的工作階段中斷後,可在終端內顯示簡短提示,或繼續使用完整對話框。首次連線失敗仍使用對話框。',
'settings.terminal.behavior.disconnectedNotice.terminal': '終端內提示',
'settings.terminal.behavior.disconnectedNotice.dialog': '對話框提示',
'settings.terminal.behavior.rightClick.menu': '顯示選單',
'settings.terminal.behavior.rightClick.paste': '貼上',
'settings.terminal.behavior.rightClick.selectWord': '選擇單字',
@@ -319,7 +324,7 @@ export const zhTWTerminalMessages: Messages = {
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
@@ -332,12 +337,18 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.forcePromptNewLine': '提示字元另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'當指令輸出的最後一行未以換行符結束時,將識別到的 shell 提示字元移動到下一行顯示。',
'settings.terminal.behavior.dynamicTabTitle': '動態標籤頁標題',
'settings.terminal.behavior.dynamicTabTitle': '動態分頁標題',
'settings.terminal.behavior.dynamicTabTitle.desc':
'選擇標籤頁什麼時候跟隨 Shell 報告的視窗標題,並更新編程 CLI 圖示。關閉時也會停止即時圖示識別。',
'選擇分頁什麼時候跟隨 Shell 報告的視窗標題,並更新編程 CLI 圖示。關閉時也會停止即時圖示識別。',
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
'settings.terminal.behavior.oscNotifications.off': '關閉',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
'settings.terminal.behavior.oscNotifications.always': '一律',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
'settings.terminal.behavior.osc52Clipboard.desc':
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
@@ -371,7 +382,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.startupCommandDelay.label': '啟動指令延遲(毫秒)',
'settings.terminal.startupCommandDelay.desc': '連線建立後等待多久再發送啟動指令;啟動指令為多行時,行與行之間也使用該間隔。慢連線可調大。',
'settings.terminal.keywordHighlight.title': '關鍵字標示',
'settings.terminal.keywordHighlight.resetColors': '重置為預設顏色',
'settings.terminal.keywordHighlight.resetColors': '重設為預設顏色',
'settings.terminal.keywordHighlight.resetDefaults': '把內建規則恢復為預設',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢復內建標籤與正規表示式',
'settings.terminal.keywordHighlight.addCustom': '新增自訂規則',
@@ -406,20 +417,20 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
'settings.terminal.connection.keepaliveCountMax.desc': '判定連線死亡前允許的無響應保活次數。值越大對短暫網路抖動和響應慢的 SSH 服務越寬容。',
'settings.terminal.connection.x11Display': 'X11 顯示地址',
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示地址。留空則使用系統預設值。',
'settings.terminal.connection.keepaliveCountMax': '最大無回應保活次數',
'settings.terminal.connection.keepaliveCountMax.desc': '判定連線死亡前允許的無回應保活次數。值越大對短暫網路抖動和回應慢的 SSH 服務越寬容。',
'settings.terminal.connection.x11Display': 'X11 顯示位址',
'settings.terminal.connection.x11Display.desc': '可選的本機 X11 顯示位址。留空則使用系統預設值。',
'settings.terminal.connection.x11Display.placeholder': '自動(:0 或 DISPLAY)',
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機位址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線位址(user@host)還是主機名稱(Vault 標籤)。',
'settings.terminal.hostInfoBar.titleMode.address': '連線位址',
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
'settings.terminal.serverStats.show': '顯示伺服器狀態',
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
@@ -441,18 +452,18 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '選擇終端渲染技術。自動模式會在低記憶體裝置上使用 DOM 渲染。更改將在新終端工作階段中生效。',
'settings.terminal.rendering.auto': '自動',
'settings.terminal.rendering.hibernateHiddenTabs': '隱藏標籤頁休眠',
'settings.terminal.rendering.hibernateHiddenTabs.desc': '標籤頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
'settings.terminal.rendering.hibernateHiddenTabs': '隱藏分頁休眠',
'settings.terminal.rendering.hibernateHiddenTabs.desc': '分頁離開視野後釋放終端渲染器以節省記憶體,SSH 連線保持不斷。檔案傳輸期間不會休眠。',
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延遲',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '標籤頁離開視野多久後釋放渲染器(5–600 秒)。',
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳過休眠',
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '分頁離開視野多久後釋放渲染器(5–600 秒)。',
'settings.terminal.rendering.hibernateSkipAltScreen': '全螢幕 TUI 跳過休眠',
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 佔用 alternate screen 時保持終端渲染器,避免慢恢復。',
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隱藏渲染器數量',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見標籤頁僅暫停 WebGL、不銷燬 xterm,超出後才完整休眠。',
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少個不可見分頁僅暫停 WebGL、不銷毀 xterm,超出後才完整休眠。',
'settings.terminal.rendering.hibernateReplayChunkBytes': '喚醒分片大小',
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢復休眠終端時每幀回放的 ANSI 位元組數(4–64 KB)。',
'settings.terminal.rendering.hibernatePreferWasmSerialize': '優先 WASM 序列化',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則回退到 JS SerializeAddon。',
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用時使用 WASM 終端序列化,否則改用 JS SerializeAddon。',
// Settings > Terminal > Inline Images
'settings.terminal.section.inlineImages': '內嵌圖片',
@@ -472,12 +483,12 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.inlineImages.sequenceLimit.desc': '開始解碼前允許接受的單條圖片跳脫序列大小上限(1–64 MB)。',
'settings.terminal.inlineImages.unit.mb': 'MB',
'settings.terminal.inlineImages.unit.megapixels': 'MP',
'settings.terminal.inlineImages.hibernateNote': '已繪製內嵌圖片的標籤頁會保留渲染器而不進入完整休眠,因為休眠快照只能還原文字。',
'settings.terminal.inlineImages.hibernateNote': '已繪製內嵌圖片的分頁會保留渲染器而不進入完整休眠,因為休眠快照只能還原文字。',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作區焦點提示',
'settings.terminal.workspaceFocus.style': '焦點提示樣式',
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標識目前聚焦的窗格。',
'settings.terminal.workspaceFocus.style.desc': '在分割檢視中如何標示目前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '為聚焦窗格顯示邊框',
@@ -503,43 +514,43 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.passwordPromptAssist.picker': '憑證選擇列表',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷鍵方案',
'settings.shortcuts.scheme.label': '鍵盤快捷鍵',
'settings.shortcuts.scheme.desc': '選擇快捷鍵使用的鍵盤佈局',
'settings.shortcuts.section.scheme': '快速鍵方案',
'settings.shortcuts.scheme.label': '鍵盤快速鍵',
'settings.shortcuts.scheme.desc': '選擇快速鍵使用的鍵盤佈局',
'settings.shortcuts.scheme.disabled': '停用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '停用終端縮放',
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
'settings.shortcuts.section.custom': '自訂快捷鍵',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '分頁快速鍵跳過固定分頁',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉分頁之間切換,不包括固定的 Vault 和 SFTP 分頁。',
'settings.shortcuts.showTabNumberBadges.label': '顯示分頁序號角標',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的分頁上顯示 1-9,方便準確切換。',
'settings.shortcuts.section.custom': '自訂快速鍵',
'settings.shortcuts.resetAll': '全部重設',
'settings.shortcuts.recording': '請按鍵...',
'settings.shortcuts.none': '無',
'settings.shortcuts.setDisabled': '設為停用',
'settings.shortcuts.category.tabs': '標籤頁',
'settings.shortcuts.category.tabs': '分頁',
'settings.shortcuts.category.terminal': '終端',
'settings.shortcuts.category.navigation': '導航',
'settings.shortcuts.category.app': '應用程式',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切換到標籤頁 [1...9]',
'settings.shortcuts.binding.next-tab': '下一個標籤頁',
'settings.shortcuts.binding.prev-tab': '上一個標籤頁',
'settings.shortcuts.binding.close-tab': '關閉標籤頁',
'settings.shortcuts.binding.switch-tab-1-9': '切換到分頁 [1...9]',
'settings.shortcuts.binding.next-tab': '下一個分頁',
'settings.shortcuts.binding.prev-tab': '上一個分頁',
'settings.shortcuts.binding.close-tab': '關閉分頁',
'settings.shortcuts.binding.close-session': '關閉工作階段窗格',
'settings.shortcuts.binding.new-tab': '新建本機標籤頁',
'settings.shortcuts.binding.new-tab': '新增本機分頁',
'settings.shortcuts.binding.copy': '從終端複製',
'settings.shortcuts.binding.paste': '貼上到終端',
'settings.shortcuts.binding.paste-selection': '將選區貼上到終端',
'settings.shortcuts.binding.select-all': '全選終端內容',
'settings.shortcuts.binding.clear-buffer': '清空終端緩衝區',
'settings.shortcuts.binding.search-terminal': '開啟終端搜尋',
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字號',
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字號',
'settings.shortcuts.binding.reset-terminal-font-size': '重置終端字號',
'settings.shortcuts.binding.increase-terminal-font-size': '增大終端字型大小',
'settings.shortcuts.binding.decrease-terminal-font-size': '減小終端字型大小',
'settings.shortcuts.binding.reset-terminal-font-size': '重設終端字型大小',
'settings.shortcuts.binding.move-focus': '在分割間移動焦點',
'settings.shortcuts.binding.split-horizontal': '水平分割',
'settings.shortcuts.binding.split-vertical': '垂直分割',
@@ -551,7 +562,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.port-forwarding': '開啟埠轉發',
'settings.shortcuts.binding.command-palette': '開啟指令面板',
'settings.shortcuts.binding.quick-switch': '快速切換',
'settings.shortcuts.binding.new-workspace': '新建工作區',
'settings.shortcuts.binding.new-workspace': '新增工作區',
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
@@ -562,7 +573,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.sftp-rename': '重新命名檔案',
'settings.shortcuts.binding.sftp-delete': '刪除檔案',
'settings.shortcuts.binding.sftp-refresh': '重新整理',
'settings.shortcuts.binding.sftp-new-folder': '新建資料夾',
'settings.shortcuts.binding.sftp-new-folder': '新增資料夾',
'settings.shortcuts.binding.sftp-open': '開啟檔案 / 進入目錄',
'settings.shortcuts.binding.sftp-go-parent': '轉到上級目錄',
'settings.shortcuts.binding.sftp-navigate-to': '轉到選取的目錄',
@@ -576,18 +587,18 @@ export const zhTWTerminalMessages: Messages = {
'hostDetails.proxyPanel.credentials': '憑證',
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
'hostDetails.proxyPanel.identities': '身分',
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身分',
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
'hostDetails.proxyPanel.customProxy': '自訂代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missing': '缺少',
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身分不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身分需要使用者名稱和密碼',
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身分密碼',
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
'hostDetails.envVars.title': '環境變數',
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
@@ -602,41 +613,41 @@ export const zhTWTerminalMessages: Messages = {
'hostDetails.chain.target': '目標',
'hostDetails.chain.availableHosts': '可用主機',
'hostDetails.chain.clear': '清空',
'hostDetails.group.title': '新建分組',
'hostDetails.group.title': '新增群組',
'hostDetails.group.general': '常規',
'hostDetails.group.namePlaceholder': '分組名稱',
'hostDetails.group.parentPlaceholder': '父分組',
'hostDetails.group.namePlaceholder': '群組名稱',
'hostDetails.group.parentPlaceholder': '父群組',
'hostDetails.group.cloudSync': '雲同步',
'hostDetails.group.addProtocol': '新增協定',
// Keychain
'keychain.filter.key': '金鑰',
'keychain.filter.certificate': '憑證',
'keychain.action.generateKey': '生成金鑰',
'keychain.action.generateKey': '產生金鑰',
'keychain.action.importKey': '匯入金鑰',
'keychain.action.newIdentity': '新建身份',
'keychain.action.newIdentity': '新增身分',
'keychain.action.importCertificate': '匯入憑證',
'keychain.view.grid': '網格',
'keychain.view.list': '列表',
'keychain.section.keys': '金鑰',
'keychain.section.identities': '身份',
'keychain.section.identities': '身分',
'keychain.count.items': '{count} 項',
'keychain.empty.title': '設定金鑰',
'keychain.empty.desc': '匯入或生成 SSH 金鑰用於安全認證。',
'keychain.panel.generateKey': '生成金鑰',
'keychain.panel.newKey': '新建金鑰',
'keychain.empty.desc': '匯入或產生 SSH 金鑰用於安全認證。',
'keychain.panel.generateKey': '產生金鑰',
'keychain.panel.newKey': '新增金鑰',
'keychain.panel.keyDetails': '金鑰詳情',
'keychain.panel.editKey': '編輯金鑰',
'keychain.panel.editIdentity': '編輯身份',
'keychain.panel.newIdentity': '新建身份',
'keychain.panel.editIdentity': '編輯身分',
'keychain.panel.newIdentity': '新增身分',
'keychain.panel.keyExport': '金鑰匯出',
'keychain.validation.labelRequired': '請填寫金鑰的 Label',
'keychain.validation.labelAndPrivateKeyRequired': 'Label 和私鑰為必填項',
'keychain.validation.labelAndUsernameRequired': 'Label 和使用者名稱為必填項',
'keychain.error.generationUnavailable': '無法生成金鑰:請確保應用程式執行在 Electron 環境',
'keychain.error.generateKeyPairFailed': '生成金鑰對失敗',
'keychain.error.generateKeyFailed': '生成金鑰失敗',
'keychain.error.keyGenerationTitle': '金鑰生成',
'keychain.error.generationUnavailable': '無法產生金鑰:請確保應用程式執行在 Electron 環境',
'keychain.error.generateKeyPairFailed': '產生金鑰對失敗',
'keychain.error.generateKeyFailed': '產生金鑰失敗',
'keychain.error.keyGenerationTitle': '金鑰產生',
'keychain.export.exportTo': '匯出到 *',
'keychain.export.selectHost': '選擇主機',
'keychain.export.location': '位置 ~ $1 *',
@@ -677,8 +688,8 @@ export const zhTWTerminalMessages: Messages = {
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
'keychain.generate.savePassphrase': '儲存 Passphrase',
'keychain.generate.generate': '生成',
'keychain.generate.generateSave': '生成並儲存',
'keychain.generate.generate': '產生',
'keychain.generate.generateSave': '產生並儲存',
'keychain.import.dropHint': '將金鑰檔案拖到這裡',
'keychain.import.importFromFile': '從檔案匯入',
'keychain.import.saveKey': '儲存金鑰',
@@ -694,7 +705,7 @@ export const zhTWTerminalMessages: Messages = {
'keychain.identity.selectCredential': '選擇{kind}',
'keychain.identity.save': '儲存',
'keychain.identity.update': '更新',
'keychain.keyDialog.newTitle': '新建金鑰',
'keychain.keyDialog.newTitle': '新增金鑰',
'keychain.keyDialog.newDesc': '新增新的 SSH 金鑰',
'keychain.keyDialog.editTitle': '編輯金鑰',
'keychain.keyDialog.editDesc': '更新此 SSH 金鑰',
@@ -706,12 +717,12 @@ export const zhTWTerminalMessages: Messages = {
'tabs.closePluginViewAria': '關閉 {title}',
'tabs.logPrefix': '日誌:',
'tabs.logLocal': '本機',
'tabs.copyTab': '複製標籤頁',
'tabs.copyTabToNewWindow': '複製標籤頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟標籤頁',
'tabs.closeOthers': '關閉其他標籤',
'tabs.closeToRight': '關閉右側標籤',
'tabs.closeAll': '關閉所有標籤',
'tabs.copyTab': '複製分頁',
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
'tabs.closeOthers': '關閉其他分頁',
'tabs.closeToRight': '關閉右側分頁',
'tabs.closeAll': '關閉所有分頁',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '金鑰 Label',
'keychain.edit.privateKeyRequired': '私鑰 *',
@@ -724,11 +735,11 @@ export const zhTWTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': '搜尋程式碼片段...',
'snippets.action.newSnippet': '新建程式碼片段',
'snippets.action.newPackage': '新建程式碼包',
'snippets.action.newSnippet': '新增程式碼片段',
'snippets.action.newPackage': '新增程式碼包',
'snippets.action.import': '匯入',
'snippets.action.selectSnippets': '選擇程式碼片段',
'snippets.panel.newTitle': '新建程式碼片段',
'snippets.panel.newTitle': '新增程式碼片段',
'snippets.panel.editTitle': '編輯程式碼片段',
'snippets.field.description': '描述',
'snippets.field.descriptionPlaceholder': '例如:check network load',
@@ -742,8 +753,8 @@ export const zhTWTerminalMessages: Messages = {
'snippets.targets.title': '執行目標',
'snippets.targets.add': '新增目標',
'snippets.targets.selectHosts': '選擇主機',
'snippets.targets.selectGroups': '選擇分組',
'snippets.targets.noGroups': '未找到分組',
'snippets.targets.selectGroups': '選擇群組',
'snippets.targets.noGroups': '未找到群組',
'snippets.history.title': 'Shell 歷史',
'snippets.history.subtitle': '{count} 條指令',
'snippets.history.emptyTitle': '暫無 Shell 歷史',
@@ -760,7 +771,7 @@ export const zhTWTerminalMessages: Messages = {
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': '建立程式碼片段',
'snippets.empty.desc': '將常用指令儲存為程式碼片段,一鍵重複使用。',
'snippets.panel.newAutomationTitle': '新建自動化指令碼',
'snippets.panel.newAutomationTitle': '新增自動化指令碼',
'snippets.panel.editAutomationTitle': '編輯自動化指令碼',
'snippets.panel.resizeWidth': '調整面板寬度',
'snippets.targets.allHosts': '對所有主機生效',
@@ -808,7 +819,7 @@ export const zhTWTerminalMessages: Messages = {
'snippets.import.conflict.hostBindingsNote': '程式碼片段的主機繫結不會被匯入或匯出。',
'snippets.import.conflict.skip': '跳過重複項',
'snippets.import.conflict.overwrite': '覆蓋重複項',
'snippets.packageDialog.title': '新建程式碼包',
'snippets.packageDialog.title': '新增程式碼包',
'snippets.packageDialog.parent': '父級:{parent}',
'snippets.packageDialog.root': '根目錄',
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
@@ -828,13 +839,13 @@ export const zhTWTerminalMessages: Messages = {
'snippets.field.multiLineRunMode.lineDelay': '逐行傳送',
'snippets.field.multiLineRunModeHint': '登入提示、網路設備巨集等情境可用逐行傳送。',
// Snippet Shortkey
'snippets.field.shortkey': '快捷鍵',
'snippets.shortkey.placeholder': '點選設定快捷鍵',
'snippets.shortkey.recording': '請按下快捷鍵組合...',
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
'snippets.shortkey.clear': '清除快捷鍵',
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
'snippets.field.shortkey': '快速鍵',
'snippets.shortkey.placeholder': '點選設定快速鍵',
'snippets.shortkey.recording': '請按下快速鍵組合...',
'snippets.shortkey.hint': '在終端中按下此快速鍵可快速傳送指令。',
'snippets.shortkey.clear': '清除快速鍵',
'snippets.shortkey.error.systemConflict': '此快速鍵與「{name}」衝突',
'snippets.shortkey.error.snippetConflict': '此快速鍵已被程式碼片段使用:{name}',
'snippets.variables.dialogTitle': '填寫變數',
'snippets.variables.dialogDesc': '執行「{label}」前請填寫以下變數。',
@@ -854,12 +865,12 @@ export const zhTWTerminalMessages: Messages = {
'serial.modal.desc': '設定序列埠連線引數',
'serial.field.port': '序列埠',
'serial.field.selectPort': '選擇序列埠...',
'serial.field.baudRate': '波特率',
'serial.field.baudRate': '鮑率',
'serial.field.dataBits': '資料位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有裝置支援',
'serial.field.stopBits': '停止位元',
'serial.field.stopBits15Warning': '1.5 停止位元在 Windows 下可能不被所有裝置支援',
'serial.field.parity': '同位位元',
'serial.field.flowControl': '流控制',
'serial.field.flowControl': '流量控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
@@ -877,16 +888,16 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.localEcho': '強制本機回顯',
'serial.field.localEchoDesc': '本機回顯輸入字元(用於沒有遠端回顯的裝置)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '緩衝輸入,按回車後傳送(而不是逐字元傳送)',
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
'serial.field.backspaceBehavior': '退格鍵',
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
'serial.backspace.default': '預設(DEL,0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
'serial.field.charset': '字元編碼',
'serial.connectionError': '連線序列埠失敗',
'serial.field.baudRatePlaceholder': '選擇或輸入波特率...',
'serial.field.baudRateEmpty': '輸入自訂波特率',
'serial.field.customBaudRate': '使用自訂波特率',
'serial.field.baudRatePlaceholder': '選擇或輸入鮑率...',
'serial.field.baudRateEmpty': '輸入自訂鮑率',
'serial.field.customBaudRate': '使用自訂鮑率',
'serial.field.saveConfig': '儲存設定',
'serial.field.saveConfigDesc': '將此序列埠設定儲存到主機列表以便快速存取',
'serial.field.configLabel': '設定名稱',
@@ -896,11 +907,11 @@ export const zhTWTerminalMessages: Messages = {
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
'keyboard.interactive.desc': '伺服器需要額外的身份驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身份驗證。',
'keyboard.interactive.response': '響應',
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
'keyboard.interactive.response': '回應',
'keyboard.interactive.enterCode': '輸入驗證碼',
'keyboard.interactive.enterResponse': '輸入響應',
'keyboard.interactive.enterResponse': '輸入回應',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '驗證中...',
'keyboard.interactive.savePassword': '儲存密碼',
+62 -52
View File
@@ -3,8 +3,8 @@ import type { Messages } from '../types';
export const zhTWVaultMessages: Messages = {
// Notes
'notes.search.placeholder': '搜尋筆記、主機、標籤...',
'notes.action.newNote': '新建筆記',
'notes.action.newGroup': '新建資料夾',
'notes.action.newNote': '新增筆記',
'notes.action.newGroup': '新增資料夾',
'notes.action.importMarkdown': '匯入 Markdown',
'notes.action.exportNote': '匯出筆記',
'notes.action.exportGroup': '匯出資料夾',
@@ -21,7 +21,7 @@ export const zhTWVaultMessages: Messages = {
'notes.codeBlock.copyFailed': '複製程式碼塊失敗。',
'notes.group.prompt': '資料夾路徑',
'notes.group.all': '筆記本',
'notes.group.ungrouped': '未分組',
'notes.group.ungrouped': '未群組',
'notes.section.notebooks': '筆記本',
'notes.section.notes': '筆記',
'notes.mode.edit': '編輯',
@@ -32,16 +32,16 @@ export const zhTWVaultMessages: Messages = {
'notes.preview.empty': '暫無內容可預覽。',
'notes.empty.title': '還沒有筆記',
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
'notes.empty.group': '這個筆記本里還沒有筆記。',
'notes.empty.group': '這個筆記本裡還沒有筆記。',
'notes.search.noResults': '沒有找到筆記。',
// Select Host panel
'selectHost.title': '選擇主機',
'selectHost.noHostsFound': '未找到主機',
'selectHost.newHost': '新建主機',
'selectHost.newHost': '新增主機',
'selectHost.continue': '繼續',
'selectHost.continueWithCount': '繼續(已選 {count} 個)',
'selectHost.toggleGroup': '切換分組 {name} 下的全部主機',
'selectHost.toggleGroup': '切換群組 {name} 下的全部主機',
'selectHost.toggleHost': '切換 {name}',
// Quick Connect
@@ -70,14 +70,14 @@ export const zhTWVaultMessages: Messages = {
'protocolSelect.port': '埠:',
// Host Details
'hostDetails.title.details': '主機詳情',
'hostDetails.title.new': '新建主機',
'hostDetails.title.new': '新增主機',
'hostDetails.saveAria': '儲存',
'hostDetails.section.address': '地址',
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 設定',
'hostDetails.sftp.fileProtocol': '檔案協定',
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時回退到 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
'hostDetails.sftp.fileProtocol.auto': '自動',
'hostDetails.sftp.fileProtocol.sftp': '僅 SFTP',
'hostDetails.sftp.fileProtocol.scp': 'SCP 模式',
@@ -112,7 +112,7 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.icon.tab.type': '型別',
'hostDetails.icon.mode.auto': '自動',
'hostDetails.icon.mode.custom': '自訂',
'hostDetails.icon.reset': '重置主機圖示',
'hostDetails.icon.reset': '重設主機圖示',
'hostDetails.icon.colorLabel': '圖示顏色',
'hostDetails.icon.colorDefault': '預設',
'hostDetails.icon.colorAuto': '自動',
@@ -152,7 +152,7 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫羅蘭',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.pink': '粉紅色',
'hostDetails.icon.color.rose': '玫瑰紅',
'hostDetails.icon.color.lime': '青檸',
'hostDetails.icon.color.teal': '藍綠色',
@@ -215,8 +215,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.auth.mfaFirst': '優先互動式驗證',
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.identity.suggestions': '身分',
'hostDetails.identity.missing': '身分不存在',
'hostDetails.credential.keyCertificate': '金鑰 / 憑證 / 本機金鑰',
'hostDetails.credential.key': '金鑰',
'hostDetails.credential.certificate': '憑證',
@@ -270,9 +270,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.algorithms.category.compress': '壓縮 (Compression)',
'hostDetails.section.keepalive': '工作階段保活',
'hostDetails.keepalive.override': '為此主機單獨設定',
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不響應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
'hostDetails.keepalive.desc': '為該主機使用專屬的保活策略,而不是跟隨全域設定。適用於不回應 keepalive@openssh.com 請求的老舊路由器 / 交換機——將間隔設為 0 可對該主機徹底關閉保活。',
'hostDetails.keepalive.interval': '間隔(秒)',
'hostDetails.keepalive.countMax': '最大無響應保活次數',
'hostDetails.keepalive.countMax': '最大無回應保活次數',
'hostDetails.keepalive.disabledHint': '間隔為 0 時該主機不傳送保活包,僅依賴 TCP 層超時偵測斷連。',
'hostDetails.section.sshTimeouts': 'SSH 連線逾時',
'hostDetails.sshTimeouts.tcpConnect': 'TCP 連線逾時(秒)',
@@ -302,18 +302,18 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '埠',
'hostDetails.telnet.credentials': '憑證',
'hostDetails.telnet.identity.placeholder': '手動身份',
'hostDetails.telnet.identity.placeholder': '手動身分',
'hostDetails.telnet.username': 'Telnet 使用者名稱',
'hostDetails.telnet.password': 'Telnet 密碼',
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
'hostDetails.telnet.add': '新增 Telnet 協定',
'hostDetails.telnet.setDefault': '預設用 Telnet 連線',
'hostDetails.tags': '標籤',
'hostDetails.group': '分組',
'hostDetails.selectGroup': '選擇分組',
'hostDetails.group': '群組',
'hostDetails.selectGroup': '選擇群組',
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立分組',
'hostDetails.createGroup': '建立群組',
'hostDetails.plugin.title': '外掛程式連線',
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
@@ -332,7 +332,7 @@ export const zhTWVaultMessages: Messages = {
// Host form (legacy modal)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.title.new': '新增主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
@@ -343,7 +343,7 @@ export const zhTWVaultMessages: Messages = {
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.field.group': '群組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
@@ -395,21 +395,24 @@ export const zhTWVaultMessages: Messages = {
'terminal.toolbar.broadcastEnable': '啟用廣播模式',
'terminal.toolbar.broadcastDisable': '關閉廣播模式',
'terminal.toolbar.composeBar': '撰寫欄',
'terminal.composeBar.placeholder': '在此輸入指令,按回車傳送...',
'terminal.composeBar.placeholder': '在此輸入指令,按 Enter 鍵傳送...',
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.resize': '拖拽調整撰寫欄高度',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
'terminal.composeBar.noPinnedSnippets': '點選 + 固定常用程式碼片段',
'terminal.composeBar.noMatchingSnippets': '沒有相符的程式碼片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 個',
'terminal.composeBar.unpinSnippet': '從快捷欄移除 {label}',
'terminal.composeBar.snippetClickHint': '單擊插入 · Shift+單擊直接傳送',
'terminal.composeBar.snippetClickHint': '點選插入 · Shift+點選直接傳送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.detach': '移出到獨立標籤',
'terminal.paneMagnification.magnify': '放大目前面板',
'terminal.paneMagnification.restore': '還原面板配置',
'terminal.paneMagnification.hint': '已放大',
'terminal.toolbar.detach': '移出到獨立分頁',
'terminal.toolbar.encoding': '終端編碼',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -423,12 +426,12 @@ export const zhTWVaultMessages: Messages = {
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改標示顏色',
'terminal.toolbar.hostHighlight.selectColor': '選擇新規則的顏色',
'terminal.statusbar.copyHostname.label': '複製主機地址',
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.statusbar.copyHostname.label': '複製主機位址',
'terminal.statusbar.copyHostname.tooltip': '複製主機位址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機位址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機位址失敗',
'terminal.statusbar.disconnect.label': '中斷連線',
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留分頁',
'terminal.statusbar.reconnect.label': '重新連線',
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
@@ -554,9 +557,9 @@ export const zhTWVaultMessages: Messages = {
// Custom Themes
'terminal.customTheme.section': '自訂主題',
'terminal.customTheme.yourThemes': '我的主題',
'terminal.customTheme.new': '新建主題',
'terminal.customTheme.new': '新增主題',
'terminal.customTheme.newDesc': '克隆目前主題並自訂',
'terminal.customTheme.newTitle': '新建自訂主題',
'terminal.customTheme.newTitle': '新增自訂主題',
'terminal.customTheme.editTitle': '編輯主題',
'terminal.customTheme.import': '匯入 .itermcolors',
'terminal.customTheme.importDesc': '從 iTerm2 配色方案檔案匯入',
@@ -599,7 +602,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.gate.confirmPlaceholder': '再次輸入密碼',
'cloudSync.gate.mismatch': '兩次輸入的密碼不一致',
'cloudSync.gate.warning':
'我已瞭解:如果忘記主金鑰,資料無法恢復,且沒有密碼重置功能。',
'我已了解:如果忘記主金鑰,資料無法恢復,且沒有密碼重設功能。',
'cloudSync.gate.enableVault': '啟用加密 Vault',
'cloudSync.gate.enabledToast': '已啟用加密 Vault',
'cloudSync.gate.setupFailed': '設定主金鑰失敗',
@@ -619,7 +622,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.provider.comingSoon': '即將支援',
'cloudSync.webdav.title': 'WebDAV 設定',
'cloudSync.webdav.desc': '設定 WebDAV 端點用於加密同步。',
'cloudSync.webdav.endpoint': '端點地址',
'cloudSync.webdav.endpoint': '端點位址',
'cloudSync.webdav.authType': '認證方式',
'cloudSync.webdav.auth.basic': 'Basic',
'cloudSync.webdav.auth.digest': 'Digest',
@@ -634,7 +637,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.webdav.validation.token': '請輸入 Token。',
'cloudSync.s3.title': 'S3 設定',
'cloudSync.s3.desc': '連線到 S3 相容物件儲存以進行加密同步。',
'cloudSync.s3.endpoint': '端點地址',
'cloudSync.s3.endpoint': '端點位址',
'cloudSync.s3.region': 'Region',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'Access Key ID',
@@ -692,7 +695,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.convergent.active': 'CRDT v2 已啟用;每次上傳後都會驗證雲端寫入。',
'cloudSync.convergent.paused': '此裝置已暫停 CRDT v2;雲端中繼資料仍會保留。',
'cloudSync.convergent.enabled': '已啟用可收斂同步',
'cloudSync.convergent.preview.title': '遷移預覽',
'cloudSync.convergent.preview.title': '移轉預覽',
'cloudSync.convergent.preview.entities': '實體',
'cloudSync.convergent.preview.providers': '雲端服務',
'cloudSync.convergent.preview.conflicts': '衝突',
@@ -760,7 +763,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.revisionHistory.keys': '金鑰',
'cloudSync.revisionHistory.snippets': '程式碼片段',
'cloudSync.revisionHistory.notes': '筆記',
'cloudSync.revisionHistory.identities': '身份',
'cloudSync.revisionHistory.identities': '身分',
'cloudSync.revisionHistory.restoreButton': '恢復此版本',
'cloudSync.revisionHistory.restored': '已從選取的修訂恢復主機庫資料。',
'cloudSync.revisionHistory.revisionNotFound': '修訂未找到或不包含主機庫資料。',
@@ -809,15 +812,15 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.conflict.detailsCounts': '本機 {local} · 雲端 {cloud} · 衝突 {conflicts}',
'cloudSync.conflict.entity.hosts': '主機',
'cloudSync.conflict.entity.keys': '金鑰',
'cloudSync.conflict.entity.identities': '身份',
'cloudSync.conflict.entity.identities': '身分',
'cloudSync.conflict.entity.proxyProfiles': '代理設定',
'cloudSync.conflict.entity.snippets': '片段',
'cloudSync.conflict.entity.notes': '筆記',
'cloudSync.conflict.entity.noteGroups': '筆記分組',
'cloudSync.conflict.entity.customGroups': '分組',
'cloudSync.conflict.entity.noteGroups': '筆記群組',
'cloudSync.conflict.entity.customGroups': '群組',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
'cloudSync.conflict.entity.groupConfigs': '分組設定',
'cloudSync.conflict.entity.groupConfigs': '群組設定',
'cloudSync.conflict.entity.settings': '設定',
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
'cloudSync.conflict.useCloud': '下載雲端(覆蓋本機)',
@@ -837,19 +840,19 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.resolve.uploaded': '已上傳本機資料',
'cloudSync.resolve.failedTitle': '衝突處理失敗',
'cloudSync.clearLocal.title': '清空本機資料',
'cloudSync.clearLocal.desc': '重置本機版本和同步歷史。下次同步將從雲端下載。',
'cloudSync.clearLocal.desc': '重設本機版本和同步歷史。下次同步將從雲端下載。',
'cloudSync.clearLocal.button': '清空',
'cloudSync.clearLocal.dialog.title': '清空本機 Vault 資料?',
'cloudSync.clearLocal.dialog.desc': '這將重置本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
'cloudSync.clearLocal.dialog.desc': '這將重設本機版本為 0 並清除同步歷史。下次同步時會從雲端下載資料,替換本機資料。',
'cloudSync.clearLocal.dialog.cancel': '取消',
'cloudSync.clearLocal.dialog.confirm': '確認清空',
'cloudSync.clearLocal.toast.title': '本機資料已清空',
'cloudSync.clearLocal.toast.desc': '本機版本已重置為 0。同步以從雲端下載資料。',
'cloudSync.clearLocal.toast.desc': '本機版本已重設為 0。同步以從雲端下載資料。',
// Common (additional)
'common.searchPlaceholder': '搜尋...',
'common.import': '匯入',
'common.generate': '生成',
'common.generate': '產生',
'common.delete': '刪除',
'common.edit': '編輯',
'sftp.context.openWithDefault': '系統預設程式開啟',
@@ -874,9 +877,9 @@ export const zhTWVaultMessages: Messages = {
'pf.form.labelPlaceholder': '規則標籤',
'pf.form.intermediateHost': '中轉主機 *',
'pf.form.createRule': '建立規則',
'pf.form.openWizard': '打開向導',
'pf.form.openWizard': '開啟精靈',
'pf.form.openWizardTitle': '開啟埠轉發精靈',
'pf.action.newForwarding': '新建轉發',
'pf.action.newForwarding': '新增轉發',
'pf.view.grid': '網格',
'pf.view.list': '列表',
'pf.rule.summary.local': '本機 {bindAddress}:{localPort} -> SSH 隧道 -> 遠端 {remoteHost}:{remotePort}',
@@ -884,7 +887,7 @@ export const zhTWVaultMessages: Messages = {
'pf.rule.summary.dynamic': 'SOCKS 監聽於 {bindAddress}:{localPort}',
'pf.tooltip.relayHost': '中轉主機',
'pf.tooltip.hostLabel': '主機',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.hostAddress': '位址',
'pf.tooltip.noHost': '未設定中轉主機',
'pf.tooltip.localDesc': '本機埠轉發:透過 SSH 隧道存取遠端服務',
'pf.tooltip.remoteDesc': '遠端埠轉發:將本機服務公開給遠端主機',
@@ -901,13 +904,20 @@ export const zhTWVaultMessages: Messages = {
'sftp.pane.selectHost': '選擇主機',
'sftp.pane.selectHostToStart': '先選擇一個主機',
'sftp.pane.chooseFilesystem': '選擇要瀏覽的本機或遠端檔案系統',
'sftp.tabs.addTab': '新建標籤頁',
'sftp.tabs.closeTab': '關閉標籤頁',
'sftp.tabs.newTab': '新標籤頁',
'sftp.tabs.copyDefaultPath': '複製標籤頁(預設路徑)',
'sftp.tabs.addTab': '新增分頁',
'sftp.tabs.closeTab': '關閉分頁',
'sftp.tabs.newTab': '新分頁',
'sftp.tabs.copyDefaultPath': '複製分頁(預設路徑)',
'sftp.tabs.copyCurrentPath': '複製並跳轉到目前路徑',
'sftp.conflict.title': '檔案衝突',
'sftp.conflict.desc': '目標位置已存在同名檔案',
'sftp.conflict.folderTitle': '資料夾衝突',
'sftp.conflict.folderDesc': '目標位置已存在同名資料夾',
'sftp.conflict.folderFileDesc': '目標位置已存在同名檔案,資料夾無法與檔案合併。',
'sftp.conflict.folderSymlinkDesc': '目標位置已存在同名符號連結,資料夾無法與符號連結合併。',
'sftp.conflict.folderUnknownDesc': '目標位置已存在同名項目,但無法確認其類型。為保護目標資料,替換操作不可用。',
'sftp.conflict.folderMergeHint': '合併會保留只存在於目標資料夾中的檔案和子資料夾。',
'sftp.conflict.folderReplaceWarning': '替換會刪除目標資料夾中所有未出現在來源資料夾裡的檔案和子資料夾,刪除的資料無法復原。',
'sftp.conflict.alreadyExistsSuffix': '已存在',
'sftp.conflict.existingFile': '已有檔案',
'sftp.conflict.newFile': '新檔案',
+2
View File
@@ -2,6 +2,7 @@ import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
@@ -9,6 +10,7 @@ export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
es,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
+43
View File
@@ -1,5 +1,6 @@
import { createContext, useContext } from 'react';
import type { useAppLockState } from './useAppLockState';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
@@ -7,6 +8,25 @@ import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
/**
* Narrow app-lock projection published on React context. The full runtime
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
* button, AppSideEffects deferral gates) only need these primitives, and
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
*/
export type AppLockChromeValue = {
appLockEnabled: boolean;
locked: boolean;
initialized: boolean;
};
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
appLockEnabled: false,
locked: false,
initialized: false,
};
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
@@ -69,6 +89,7 @@ class RuntimeSlot<T> {
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
@@ -106,9 +127,22 @@ export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
appLockSlot.set(runtime);
}
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
return appLockSlot.get();
}
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
return appLockSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
@@ -136,3 +170,12 @@ export function useAppSettingsRuntime(): AppSettingsRuntime {
}
return runtime;
}
/**
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
* Falls back to an "unlocked / disabled" default so windows without a
* publisher (isolated mounts, tests) behave as if App Lock is off.
*/
export function useAppLockChrome(): AppLockChromeValue {
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
}
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
handleTerminalOscNotification,
showOscDesktopNotification,
} from "./oscDesktopNotifications.ts";
const installNotificationBridge = () => {
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
netcatty: {
showSystemNotification: async (payload) => {
calls.push(payload);
return { shown: true };
},
},
};
return {
calls,
restore() {
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
else (globalThis as { window: unknown }).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
},
};
};
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
const fixture = installNotificationBridge();
try {
showOscDesktopNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "", body: "quiet", protocol: "osc9" },
mode: "unfocused",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
assert.deepEqual(fixture.calls, [{
title: "Codex",
body: "Turn complete",
sessionId: "s1",
}]);
} finally {
fixture.restore();
}
});
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
const fixture = installNotificationBridge();
let activity = 0;
try {
assert.equal(handleTerminalOscNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s-off",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), false);
assert.equal(activity, 0);
assert.equal(fixture.calls.length, 0);
assert.equal(handleTerminalOscNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: false,
sessionId: "s-on",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), true);
assert.equal(activity, 1);
assert.equal(fixture.calls.length, 1);
} finally {
fixture.restore();
}
});
@@ -0,0 +1,60 @@
import {
DEFAULT_OSC_NOTIFICATION_TITLE,
OscNotificationLimiter,
resolveOscNotificationPresentation,
shouldShowOscDesktopNotification,
type OscNotification,
} from "../../domain/terminalOscNotifications";
import type { OscNotificationMode } from "../../domain/models/terminal";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const sessionLimiters = new Map<string, OscNotificationLimiter>();
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
const existing = sessionLimiters.get(sessionId);
if (existing) return existing;
const limiter = new OscNotificationLimiter();
sessionLimiters.set(sessionId, limiter);
return limiter;
};
export function showOscDesktopNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
}): boolean {
if (!shouldShowOscDesktopNotification(options.mode, {
windowFocused: typeof document !== "undefined" && document.hasFocus(),
sessionFocused: options.sessionFocused,
})) {
return false;
}
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
const presented = resolveOscNotificationPresentation(
options.notification,
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
);
void netcattyBridge.get()?.showSystemNotification?.({
title: presented.title,
body: presented.body,
sessionId: options.sessionId,
});
return true;
}
export function handleTerminalOscNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
onSessionActivity?: () => void;
}): boolean {
if (options.mode === "off") return false;
options.onSessionActivity?.();
showOscDesktopNotification(options);
return true;
}
@@ -111,6 +111,8 @@ test("session peer windows do not run main-window startup effects", () => {
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
const appLockGateSource = readFileSync(new URL("../../components/AppLockGate.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
@@ -119,12 +121,17 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
assert.match(appLockGateSource, /settingsOptions\?: Parameters<typeof useSettingsState>\[0\]/);
assert.match(appLockGateSource, /deps\.useSettingsState\(settingsOptions\)/);
assert.match(indexSource, /const isPeerSessionWindow = window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(indexSource, /const settingsOptions = isPeerSessionWindow\s*\?\s*\{ enableSettingsSync: false, enableSystemEffects: false \}/);
assert.match(indexSource, /<AppLockGate settingsOptions=\{settingsOptions\}>/);
// AppLockGate owns useSettingsState; App forwards the gate's instance into
// SettingsPublisher so the runtime slot/context still publish it.
assert.match(appRootSource, /<SettingsPublisher settings=\{settings\}>/);
assert.doesNotMatch(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
/useSettingsState\(/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
@@ -125,6 +125,30 @@ test("closing a workspace session dissolves the workspace when one terminal rema
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s1",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
});
test("closing a non-focused session keeps the focused session", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
+18 -1
View File
@@ -96,7 +96,24 @@ export function closeSessionWorkspaceLayoutState(
return null;
}
return { ...workspace, root: prunedRoot };
// Closing the focused session must hand focus to a remaining one, or a
// focus-mode workspace is left with a dangling focusedSessionId: no pane
// qualifies as visible and the continuously rendered hidden panes all
// paint on top of each other (#3046).
const nextFocusedSessionId =
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
? remainingSessionIds[0]
: workspace.focusedSessionId;
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
return {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
};
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
+4 -1
View File
@@ -1,4 +1,4 @@
export type SortField = "name" | "size" | "modified" | "type";
export type SortField = "name" | "size" | "modified" | "type" | "owner";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
@@ -6,6 +6,7 @@ export interface ColumnWidths {
modified: number;
size: number;
type: number;
owner: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
@@ -15,6 +16,7 @@ export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
modified: true,
size: true,
type: true,
owner: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
@@ -28,5 +30,6 @@ export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibil
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
owner: stored.owner !== false,
};
};
@@ -127,3 +127,13 @@ test("live and restart-resume directory replacement both call the shared promoti
assert.match(liveSource, /promoteDirectoryReplacePaths\(\{/);
assert.match(resumeSource, /promoteDirectoryReplacePaths\(\{/);
});
test("live directory replacement bypasses the merge-only same-host copy shortcut", () => {
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
const sameHostCopyGuard = liveSource.slice(
liveSource.indexOf("if (\n task.isDirectory"),
liveSource.indexOf("sameHostCopyDirectory!", liveSource.indexOf("if (\n task.isDirectory")),
);
assert.match(sameHostCopyGuard, /!task\.replaceExistingTarget/);
});
@@ -0,0 +1,22 @@
type SftpFilterFocusListener = () => void;
const listenersByPaneId = new Map<string, Set<SftpFilterFocusListener>>();
export const sftpFilterFocusStore = {
request(paneId: string): void {
listenersByPaneId.get(paneId)?.forEach((listener) => listener());
},
subscribe(paneId: string, listener: SftpFilterFocusListener): () => void {
const listeners = listenersByPaneId.get(paneId) ?? new Set<SftpFilterFocusListener>();
listeners.add(listener);
listenersByPaneId.set(paneId, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
listenersByPaneId.delete(paneId);
}
};
},
};
@@ -28,6 +28,7 @@ export const useSftpDirectoryListing = () => {
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
owner: f.owner,
};
});
},
@@ -50,6 +51,7 @@ export const useSftpDirectoryListing = () => {
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
+4 -2
View File
@@ -31,10 +31,11 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 56,
modified: 28,
name: 50,
modified: 24,
size: 7,
type: 9,
owner: 10,
});
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
normalizeSftpColumnVisibility(
@@ -107,6 +108,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
owner: { min: 6, max: 20 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
+8 -1
View File
@@ -845,7 +845,14 @@ export const useSftpTransfers = ({
// Try same-host directory optimization first; falls back to recursive transfer
// if remote cp is unavailable (e.g. Windows SSH servers).
let dirHandledBySameHost = false;
if (task.isDirectory && task.resumable === false && sameHost && encodingSafeForExec && sourceSftpId) {
if (
task.isDirectory
&& !task.replaceExistingTarget
&& task.resumable === false
&& sameHost
&& encodingSafeForExec
&& sourceSftpId
) {
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
@@ -36,3 +36,32 @@ test('cleanup only removes the handler that created it', () => {
assert.equal(registry.request('session-1'), true);
assert.deepEqual(requests, ['current']);
});
test('active reconnect state blocks duplicate requests and notifies subscribers', () => {
const registry = createTerminalReconnectRegistry();
const requests: string[] = [];
let notifications = 0;
const unsubscribe = registry.subscribe(() => { notifications += 1; });
registry.register('session-1', () => requests.push('session-1'));
registry.setActive('session-1', true);
assert.equal(registry.isActive('session-1'), true);
assert.equal(registry.request('session-1'), false);
assert.deepEqual(requests, []);
registry.setActive('session-1', false);
assert.equal(registry.request('session-1'), true);
assert.deepEqual(requests, ['session-1']);
assert.equal(notifications, 2);
unsubscribe();
});
test('unregistering the current handler clears active reconnect state', () => {
const registry = createTerminalReconnectRegistry();
const unregister = registry.register('session-1', () => {});
registry.setActive('session-1', true);
unregister();
assert.equal(registry.isActive('session-1'), false);
});
+26 -1
View File
@@ -3,6 +3,29 @@ type TerminalReconnectHandler = () => void;
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const listeners = new Set<() => void>();
const emit = () => {
for (const listener of listeners) listener();
};
const setActive = (sessionId: string, active: boolean): void => {
const changed = active
? !activeSessionIds.has(sessionId)
: activeSessionIds.has(sessionId);
if (!changed) return;
if (active) activeSessionIds.add(sessionId);
else activeSessionIds.delete(sessionId);
emit();
};
const isActive = (sessionId: string): boolean => activeSessionIds.has(sessionId);
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
handlers.set(sessionId, handler);
@@ -12,11 +35,13 @@ export const createTerminalReconnectRegistry = () => {
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
setActive(sessionId, false);
}
};
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
if (!handler) {
pendingRequests.add(sessionId);
@@ -26,7 +51,7 @@ export const createTerminalReconnectRegistry = () => {
return true;
};
return { register, request };
return { register, request, setActive, isActive, subscribe };
};
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
+1
View File
@@ -103,6 +103,7 @@ export interface SendToCattyContext {
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
commandTimeout?: number;
responseIdleTimeout?: number;
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
+26
View File
@@ -10,6 +10,7 @@ import {
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
@@ -29,7 +30,9 @@ import type {
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
@@ -74,6 +77,12 @@ export function useAISettingsState() {
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
normalizeResponseIdleTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
@@ -175,6 +184,12 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setResponseIdleTimeout = useCallback((value: number) => {
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
setResponseIdleTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
@@ -249,6 +264,13 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) break;
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) break;
@@ -315,6 +337,8 @@ export function useAISettingsState() {
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
@@ -346,6 +370,8 @@ export function useAISettingsState() {
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { resolveInheritedAIActiveSessionId } from '../../domain/aiWorkspaceScopeInherit.ts';
import { resolveDisplayedPanelView } from '../../components/ai/aiPanelViewState.ts';
import type { AISession } from '../../infrastructure/ai/types.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { useAIState } from './useAIState.ts';
const SCOPE_KEY = 'workspace:workspace-old';
const RESTORED_SESSION: AISession = {
id: 'chat-old',
title: 'Restored chat',
agentId: 'catty',
scope: {
type: 'terminal',
targetId: 'terminal-a',
hostIds: ['host-a'],
},
messages: [
{ id: 'user-1', role: 'user', content: 'old question', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'old answer', timestamp: 2 },
],
createdAt: 1,
updatedAt: 2,
};
function NewChatHarness() {
const ai = useAIState();
const state = useAISessionsStore();
const visibleSessionIds = new Set(state.sessions.map((session) => session.id));
const inheritedSessionId = resolveInheritedAIActiveSessionId({
scopeType: 'workspace',
scopeTargetId: 'workspace-old',
activeSessionIdMap: state.activeSessionIdMap,
memberTerminalIds: ['terminal-a', 'terminal-b'],
preferredTerminalId: 'terminal-a',
visibleSessionIds,
});
const view = resolveDisplayedPanelView(
state.panelViewByScope[SCOPE_KEY],
state.draftsByScope[SCOPE_KEY] != null,
state.sessions as AISession[],
inheritedSessionId,
'workspace',
);
return (
<button
type="button"
data-view={view.mode}
data-session={view.mode === 'session' ? view.sessionId : ''}
onClick={() => {
ai.clearDraftForScope(SCOPE_KEY);
ai.updateDraft(SCOPE_KEY, 'catty', () => ({
text: '',
agentId: 'catty',
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
}));
ai.showDraftView(SCOPE_KEY);
}}
>
New Chat
</button>
);
}
test('restored workspace enters a blank draft when New Chat is clicked', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: dom.window,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: dom.window.document,
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: dom.window.localStorage,
});
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: dom.window.CustomEvent,
});
Object.defineProperty(globalThis, 'Event', {
configurable: true,
value: dom.window.Event,
});
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([RESTORED_SESSION]));
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify({
'terminal:terminal-a': RESTORED_SESSION.id,
[SCOPE_KEY]: RESTORED_SESSION.id,
}));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<NewChatHarness />);
});
const button = container.querySelector('button');
assert.ok(button);
assert.equal(button.dataset.view, 'session');
assert.equal(button.dataset.session, RESTORED_SESSION.id);
await act(async () => {
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
assert.equal(button.dataset.view, 'draft');
assert.equal(button.dataset.session, '');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
+111 -29
View File
@@ -11,11 +11,13 @@ import {
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
} from '../../infrastructure/config/storageKeys';
@@ -37,7 +39,9 @@ import type {
import {
DEFAULT_COMMAND_BLOCKLIST,
DEFAULT_COMMAND_TIMEOUT_SECONDS,
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
normalizeCommandTimeoutSeconds,
normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types';
import {
activateDraftView,
@@ -79,6 +83,28 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
function providerPatchIsNoop(
current: ProviderConfig,
updates: Partial<ProviderConfig>,
): boolean {
for (const key of Object.keys(updates) as Array<keyof ProviderConfig>) {
const nextValue = updates[key];
const prevValue = current[key];
if (nextValue === prevValue) continue;
if (key === 'modelContextWindows') {
const prevWindows = (prevValue ?? {}) as Record<string, number>;
const nextWindows = (nextValue ?? {}) as Record<string, number>;
const nextKeys = Object.keys(nextWindows);
if (nextKeys.length !== Object.keys(prevWindows).length) return false;
if (nextKeys.some((modelId) => prevWindows[modelId] !== nextWindows[modelId])) return false;
continue;
}
return false;
}
return true;
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -122,6 +148,12 @@ export function useAIState() {
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
)
);
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
normalizeResponseIdleTimeoutSeconds(
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
)
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
@@ -174,6 +206,9 @@ export function useAIState() {
useEffect(() => {
agentProviderMapRef.current = agentProviderMap;
}, [agentProviderMap]);
const [agentThinkingMap, setAgentThinkingMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
@@ -270,6 +305,7 @@ export function useAIState() {
const setAgentModel = useCallback((agentId: string, modelId: string) => {
setAgentModelMapRaw(prev => {
if (prev[agentId] === modelId) return prev;
const next = { ...prev, [agentId]: modelId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
return next;
@@ -280,17 +316,36 @@ export function useAIState() {
setAgentProviderMapRaw(prev => {
// Empty string clears the per-agent override and lets the agent fall
// back to the global `activeProviderId`.
const next = { ...prev };
if (providerId) {
next[agentId] = providerId;
} else {
delete next[agentId];
if (prev[agentId] === providerId) return prev;
const next = { ...prev, [agentId]: providerId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
}
if (!(agentId in prev)) return prev;
const next = { ...prev };
delete next[agentId];
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
});
}, []);
const setAgentThinking = useCallback((agentId: string, thinkingLevel: string) => {
setAgentThinkingMapRaw((prev) => {
if (thinkingLevel) {
if (prev[agentId] === thinkingLevel) return prev;
const next = { ...prev, [agentId]: thinkingLevel };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
return next;
}
if (!(agentId in prev)) return prev;
const next = { ...prev };
delete next[agentId];
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -314,6 +369,7 @@ export function useAIState() {
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
if (next === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
@@ -382,6 +438,12 @@ export function useAIState() {
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
}, []);
const setResponseIdleTimeout = useCallback((value: number) => {
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
setResponseIdleTimeoutRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
@@ -462,6 +524,16 @@ export function useAIState() {
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
break;
}
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
if (!Number.isFinite(timeout)) {
console.warn('[useAIState] Cross-window sync: AI_RESPONSE_IDLE_TIMEOUT is not a finite number, skipping');
break;
}
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) {
@@ -493,6 +565,9 @@ export function useAIState() {
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
break;
case STORAGE_KEY_AI_AGENT_THINKING_MAP:
setAgentThinkingMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -876,34 +951,25 @@ export function useAIState() {
}, []);
const showDraftView = useCallback((scopeKey: string) => {
const currentPanelViewByScope = panelViewByScope;
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let activeSessionMapChanged = false;
let panelViewChanged = false;
const currentActiveSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const next = activateDraftView(
currentActiveSessionIdMap,
panelViewByScope,
scopeKey,
);
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
const next = activateDraftView(
prevActiveSessionIdMap,
currentPanelViewByScope,
scopeKey,
);
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
nextActiveSessionIdMap = next.activeSessionIdMap;
nextPanelViewByScope = next.panelViewByScope;
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
});
if (activeSessionMapChanged && nextActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
if (next.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(next.activeSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next.activeSessionIdMap);
setActiveSessionIdMapRaw(next.activeSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (panelViewChanged && nextPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
setPanelViewByScopeRaw(nextPanelViewByScope);
if (next.panelViewByScope !== panelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(next.panelViewByScope);
setPanelViewByScopeRaw(next.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}, [panelViewByScope]);
@@ -1113,7 +1179,15 @@ export function useAIState() {
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
setProviders((prev) => {
const index = prev.findIndex((provider) => provider.id === id);
if (index < 0) return prev;
const current = prev[index];
if (providerPatchIsNoop(current, updates)) return prev;
const next = prev.slice();
next[index] = { ...current, ...updates };
return next;
});
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
@@ -1180,12 +1254,16 @@ export function useAIState() {
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -1238,12 +1316,16 @@ export function useAIState() {
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
webSearchConfig,
setWebSearchConfig,
quickMessages,
+79
View File
@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { RuntimeAppLockReason } from './useAppLockRuntime';
let rendererReadySent = false;
export function useAppLockBridge() {
const getRuntimeState = useCallback(async () => {
return netcattyBridge.get()?.getAppLockRuntimeState?.();
}, []);
const getSettings = useCallback(async () => {
return netcattyBridge.get()?.getAppLockSettings?.();
}, []);
const setRuntimeLocked = useCallback(async (reason: Exclude<RuntimeAppLockReason, null>) => {
return netcattyBridge.get()?.setAppLockRuntimeLocked?.(reason);
}, []);
const requestUnlock = useCallback(async (password: string) => {
return netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false as const, error: 'incorrect' as const };
}, []);
const requestReset = useCallback(async (currentPassword: string) => {
return netcattyBridge.get()?.requestAppLockReset?.(currentPassword);
}, []);
const reportActivity = useCallback(async () => {
await netcattyBridge.get()?.reportAppLockActivity?.();
}, []);
const onRuntimeStateChanged = useCallback((listener: (state: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockRuntimeStateChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const onSettingsChanged = useCallback((listener: (settings: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockSettingsChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const notifyRendererReady = useCallback(() => {
if (rendererReadySent) return;
rendererReadySent = true;
try {
netcattyBridge.get()?.rendererReady?.();
} catch {
// ignore
}
}, []);
const onAppLockReopen = useCallback((listener: () => void) => {
try {
return netcattyBridge.get()?.onAppLockReopen?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
return {
getRuntimeState,
getSettings,
setRuntimeLocked,
requestUnlock,
requestReset,
reportActivity,
onRuntimeStateChanged,
onSettingsChanged,
notifyRendererReady,
onAppLockReopen,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeRuntimeAppLockState,
selectPreferredRuntimeAppLockState,
} from "./useAppLockRuntime.ts";
test("normalizeRuntimeAppLockState defaults safely", () => {
assert.deepEqual(normalizeRuntimeAppLockState(null), {
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
});
test("normalizeRuntimeAppLockState preserves a valid runtime payload", () => {
assert.deepEqual(
normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
}),
{
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
},
);
});
test("selectPreferredRuntimeAppLockState keeps the newer runtime version", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastLockedAt: 100,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const staleIncoming = normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 50,
lastUnlockedAt: null,
lastActivityAt: 60,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, staleIncoming),
current,
);
});
test("selectPreferredRuntimeAppLockState prefers initialized state when versions tie", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const uninitializedIncoming = normalizeRuntimeAppLockState({
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, uninitializedIncoming),
current,
);
});
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState } from 'react';
export type RuntimeAppLockReason = 'startup' | 'idle' | 'manual' | 'background' | null;
export interface RuntimeAppLockState {
initialized: boolean;
locked: boolean;
reason: RuntimeAppLockReason;
version: number;
lastLockedAt: number | null;
lastUnlockedAt: number | null;
lastActivityAt: number | null;
}
export function normalizeRuntimeAppLockState(input: unknown): RuntimeAppLockState {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const reason = record?.reason;
return {
initialized: record?.initialized === true,
locked: record?.locked === true,
reason:
reason === 'startup' ||
reason === 'idle' ||
reason === 'manual' ||
reason === 'background'
? reason
: null,
version: typeof record?.version === 'number' && Number.isFinite(record.version) ? record.version : 0,
lastLockedAt: typeof record?.lastLockedAt === 'number' && Number.isFinite(record.lastLockedAt) ? record.lastLockedAt : null,
lastUnlockedAt: typeof record?.lastUnlockedAt === 'number' && Number.isFinite(record.lastUnlockedAt) ? record.lastUnlockedAt : null,
lastActivityAt: typeof record?.lastActivityAt === 'number' && Number.isFinite(record.lastActivityAt) ? record.lastActivityAt : null,
};
}
const DEFAULT_RUNTIME_APP_LOCK_STATE: RuntimeAppLockState = normalizeRuntimeAppLockState(null);
export function selectPreferredRuntimeAppLockState(
current: RuntimeAppLockState,
incoming: RuntimeAppLockState,
): RuntimeAppLockState {
if (incoming.version > current.version) return incoming;
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
return incoming;
}
export function useAppLockRuntime(
bridge: Pick<
NonNullable<NetcattyBridge>,
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
> | null | undefined,
) {
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(DEFAULT_RUNTIME_APP_LOCK_STATE);
const refreshRuntimeState = useCallback(async () => {
const nextState = await bridge?.getAppLockRuntimeState?.();
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
return normalized;
}, [bridge]);
useEffect(() => {
let cancelled = false;
const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
if (cancelled) return;
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
}) ?? (() => {});
void refreshRuntimeState().catch(() => {
if (cancelled) return;
setRuntimeState((current) => selectPreferredRuntimeAppLockState(
current,
DEFAULT_RUNTIME_APP_LOCK_STATE,
));
});
return () => {
cancelled = true;
unsubscribe();
};
}, [bridge, refreshRuntimeState]);
return {
runtimeState,
setRuntimeState,
refreshRuntimeState,
};
}
+182
View File
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AppLockPasswordVerifier, AppLockSettings } from "../../domain/appLock.ts";
import {
createOptimisticUnlockedRuntimeState,
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
getIdleLockDelayMs,
normalizeAppLockSystemUnlockStatus,
normalizeAppLockSystemUnlockResult,
resolveUnlockAttempt,
shouldLockAfterIdle,
shouldLockOnStartup,
} from "./useAppLockState.ts";
import { selectPreferredRuntimeAppLockState } from "./useAppLockRuntime.ts";
const verifier: AppLockPasswordVerifier = {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
};
test("shouldLockOnStartup locks only when enabled with a verifier", () => {
const enabled: AppLockSettings = {
enabled: true,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockOnStartup(enabled), true);
assert.equal(shouldLockOnStartup({ ...enabled, enabled: false }), false);
assert.equal(shouldLockOnStartup({ ...enabled, passwordVerifier: null }), false);
});
test("shouldLockAfterIdle honors the configured timeout", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000 - 1), false);
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000), true);
assert.equal(shouldLockAfterIdle({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, enabled: false }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, passwordVerifier: null }, 1_000, 1_000 + 60 * 60_000), false);
});
test("getIdleLockDelayMs schedules the next check after remaining idle time", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000), 5 * 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 4 * 60_000), 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 5 * 60_000), 0);
assert.equal(getIdleLockDelayMs({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, enabled: false }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, passwordVerifier: null }, 1_000, 1_000), null);
});
test("createOptimisticUnlockedRuntimeState clears stale locked state after successful unlock", () => {
const nextState = createOptimisticUnlockedRuntimeState(
{
initialized: false,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.deepEqual(nextState, {
initialized: true,
locked: false,
reason: null,
// Optimistic unlock must not invent a higher version than main has sent.
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: 5_000,
lastActivityAt: 5_000,
});
});
test("createOptimisticUnlockedRuntimeState keeps observed version so concurrent re-lock can win", () => {
const optimistic = createOptimisticUnlockedRuntimeState(
{
initialized: true,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.equal(optimistic.version, 7);
assert.equal(optimistic.locked, false);
// A concurrent main re-lock at version 8 must outrank the optimistic unlock.
const preferred = selectPreferredRuntimeAppLockState(optimistic, {
...optimistic,
locked: true,
reason: "manual",
version: 8,
lastLockedAt: 6_000,
});
assert.equal(preferred.locked, true);
assert.equal(preferred.version, 8);
});
test("normalizes app lock system unlock bridge status and results", () => {
assert.deepEqual(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS, {
supported: false,
available: false,
enabled: false,
platform: "unsupported",
label: null,
reason: null,
});
assert.deepEqual(
normalizeAppLockSystemUnlockStatus({
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: "",
}),
{
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: null,
},
);
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: true }), { ok: true });
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "cancelled" }), {
ok: false,
error: "cancelled",
});
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "unknown" }), {
ok: false,
error: "failed",
});
});
test("resolveUnlockAttempt validates empty, incorrect, and correct passwords", async () => {
const originalWindow = globalThis.window;
globalThis.window = {
netcatty: {
requestAppLockUnlock: async (password: string) =>
password === "secret"
? { ok: true as const }
: { ok: false as const, error: "incorrect" as const },
},
} as typeof window;
try {
assert.deepEqual(await resolveUnlockAttempt(""), { ok: false, error: "empty" });
assert.deepEqual(await resolveUnlockAttempt("wrong"), { ok: false, error: "incorrect" });
assert.deepEqual(await resolveUnlockAttempt("secret"), { ok: true });
} finally {
globalThis.window = originalWindow;
}
});
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
normalizeAppLockSettings,
type AppLockSettings,
} from '../../domain/appLock';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
normalizeRuntimeAppLockState,
useAppLockRuntime,
type RuntimeAppLockReason,
type RuntimeAppLockState,
} from './useAppLockRuntime';
export type AppLockReason = RuntimeAppLockReason;
export type AppLockUnlockResult =
| { ok: true }
| { ok: false; error: 'empty' | 'incorrect' };
export interface AppLockSystemUnlockStatus {
supported: boolean;
available: boolean;
enabled: boolean;
platform: 'darwin' | 'win32' | 'unsupported';
label: 'Touch ID' | 'Windows Hello' | null;
reason: string | null;
}
export type AppLockSystemUnlockResult =
| { ok: true }
| { ok: false; error: 'disabled' | 'not-locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' };
export const DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS: AppLockSystemUnlockStatus = {
supported: false,
available: false,
enabled: false,
platform: 'unsupported',
label: null,
reason: null,
};
export function normalizeAppLockSystemUnlockStatus(input: unknown): AppLockSystemUnlockStatus {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const platform = record?.platform === 'darwin' || record?.platform === 'win32'
? record.platform
: 'unsupported';
const label = record?.label === 'Touch ID' || record?.label === 'Windows Hello'
? record.label
: null;
return {
supported: record?.supported === true,
available: record?.available === true,
enabled: record?.enabled === true,
platform,
label,
reason: typeof record?.reason === 'string' && record.reason ? record.reason : null,
};
}
export function normalizeAppLockSystemUnlockResult(input: unknown): AppLockSystemUnlockResult {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
if (record?.ok === true) return { ok: true };
const error = record?.error;
if (
error === 'disabled' ||
error === 'not-locked' ||
error === 'unsupported' ||
error === 'unavailable' ||
error === 'cancelled' ||
error === 'failed'
) {
return { ok: false, error };
}
return { ok: false, error: 'failed' };
}
export function shouldLockOnStartup(settings: AppLockSettings): boolean {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled && normalized.passwordVerifier !== null;
}
export function shouldLockAfterIdle(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): boolean {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return false;
if (normalized.timeoutMinutes <= 0) return false;
return now - lastActivityAt >= normalized.timeoutMinutes * 60_000;
}
export function getIdleLockDelayMs(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): number | null {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return null;
if (normalized.timeoutMinutes <= 0) return null;
const timeoutMs = normalized.timeoutMinutes * 60_000;
return Math.max(0, timeoutMs - (now - lastActivityAt));
}
export async function resolveUnlockAttempt(password: string): Promise<AppLockUnlockResult> {
if (!password) return { ok: false, error: 'empty' };
try {
return await netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false, error: 'incorrect' };
} catch {
return { ok: false, error: 'incorrect' };
}
}
export function createOptimisticUnlockedRuntimeState(
input: RuntimeAppLockState,
now: number,
): RuntimeAppLockState {
const current = normalizeRuntimeAppLockState(input);
if (current.initialized && !current.locked && current.reason === null) {
return current;
}
return {
...current,
initialized: true,
locked: false,
reason: null,
// Keep the observed main-process version. Bumping here can outrank a real
// concurrent re-lock and leave the renderer unlocked after refresh (Codex P2).
version: current.version,
lastUnlockedAt: now,
lastActivityAt: now,
};
}
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
const bridge = netcattyBridge.get();
const { runtimeState, refreshRuntimeState, setRuntimeState } = useAppLockRuntime(bridge);
const [systemUnlockStatus, setSystemUnlockStatus] = useState<AppLockSystemUnlockStatus>(
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
);
const normalizedRuntimeState = useMemo(
() => normalizeRuntimeAppLockState(runtimeState),
[runtimeState],
);
const effectiveRuntimeState = useMemo(() => {
if (normalizedRuntimeState.initialized) return normalizedRuntimeState;
if (shouldLockOnStartup(normalizedSettings)) {
return {
...normalizedRuntimeState,
locked: true,
reason: 'startup' as const,
};
}
return normalizedRuntimeState;
}, [normalizedRuntimeState, normalizedSettings]);
const lockNow = useCallback((reason: AppLockReason = 'manual') => {
if (!shouldLockOnStartup(normalizedSettings) || !reason) return;
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
const unlock = useCallback(async (password: string): Promise<AppLockUnlockResult> => {
const result = await resolveUnlockAttempt(password);
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
return result;
}, [refreshRuntimeState, setRuntimeState]);
const refreshSystemUnlockStatus = useCallback(async () => {
try {
const nextStatus = await bridge?.getAppLockSystemUnlockStatus?.();
const normalized = normalizeAppLockSystemUnlockStatus(nextStatus);
setSystemUnlockStatus(normalized);
return normalized;
} catch {
setSystemUnlockStatus(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS);
return DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS;
}
}, [bridge]);
const unlockWithSystemAuth = useCallback(async (): Promise<AppLockSystemUnlockResult> => {
const result = normalizeAppLockSystemUnlockResult(await bridge?.requestAppLockSystemUnlock?.());
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
await refreshSystemUnlockStatus().catch(() => {});
return result;
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
const reset = useCallback(async (currentPassword: string) => {
if (typeof bridge?.requestAppLockReset !== 'function') {
throw new Error('App Lock reset bridge is unavailable');
}
const result = await bridge.requestAppLockReset(currentPassword);
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
throw new Error(result.error);
}
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}, [bridge, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings) && effectiveRuntimeState.locked) {
const unlockedAt = Date.now();
void bridge?.requestAppLockUnlock?.('')
?.then((result) => {
if (result?.ok !== true) return;
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
return refreshRuntimeState();
})
.catch(() => {});
}
}, [bridge, effectiveRuntimeState.locked, normalizedSettings, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'focus'];
for (const eventName of events) {
window.addEventListener(eventName, recordActivity, { passive: true });
}
return () => {
for (const eventName of events) {
window.removeEventListener(eventName, recordActivity);
}
};
}, [normalizedSettings, recordActivity]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
void bridge?.reportAppLockActivity?.();
return undefined;
}, [bridge, normalizedSettings]);
useEffect(() => {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
return {
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
resync: refreshRuntimeState,
};
}
+88
View File
@@ -11,6 +11,11 @@ import {
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
normalizeAppLockTimeoutMinutes,
type AppLockSettings,
type AppLockTimeoutMinutes,
} from '../../domain/appLock';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
@@ -338,6 +343,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [appLockSettings, setAppLockSettingsState] = useState<AppLockSettings>({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
@@ -787,6 +799,53 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const setAppLockTimeoutMinutes = useCallback((timeoutMinutes: AppLockTimeoutMinutes) => {
void netcattyBridge.get()?.setAppLockTimeoutMinutes?.(normalizeAppLockTimeoutMinutes(timeoutMinutes))
?.then((next) => {
if (next) setAppLockSettingsState(next);
})
.catch(() => {});
}, []);
const requestAppLockEnable = useCallback(async () => {
const next = await netcattyBridge.get()?.requestAppLockEnable?.();
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockDisable = useCallback(async (currentPassword: string) => {
const next = await netcattyBridge.get()?.requestAppLockDisable?.(currentPassword);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockPasswordChange = useCallback(async (input: {
currentPassword?: string;
nextPassword: string;
}) => {
const next = await netcattyBridge.get()?.requestAppLockPasswordChange?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const setAppLockSystemUnlockEnabled = useCallback(async (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => {
const next = await netcattyBridge.get()?.setAppLockSystemUnlockEnabled?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
const current = appearanceStateRef.current;
const nextAppearance = resolveAppearanceSyncState(
@@ -863,6 +922,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
void netcattyBridge.get()?.getAppLockSettings?.().then((next) => {
if (next) setAppLockSettingsState(next);
}).catch(() => {});
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
@@ -1155,6 +1218,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
};
}, [enableSettingsSync]);
useEffect(() => {
const bridge = netcattyBridge.get();
let sawPushedSettings = false;
const unsubscribe = bridge?.onAppLockSettingsChanged?.((next) => {
sawPushedSettings = true;
setAppLockSettingsState(next);
}) ?? (() => {});
void bridge?.getAppLockSettings?.().then((next) => {
if (!next || sawPushedSettings) return;
setAppLockSettingsState(next);
}).catch(() => {});
return () => {
unsubscribe();
};
}, []);
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -1914,6 +1996,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
updateSyncConfig,
uiLanguage,
setUiLanguage,
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockEnable,
requestAppLockDisable,
requestAppLockPasswordChange,
setAppLockSystemUnlockEnabled,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
+96 -1
View File
@@ -1,7 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
import { matchesKeyBinding } from "../../domain/models.ts";
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
test("primary-modifier W only belongs to the matching close-tab binding", () => {
assert.equal(isPrimaryModifierWBinding("⌘ + W", matchesKeyBinding, true), true);
assert.equal(isPrimaryModifierWBinding("⌘ + E", matchesKeyBinding, true), false);
assert.equal(isPrimaryModifierWBinding("Ctrl + W", matchesKeyBinding, false), true);
assert.equal(isPrimaryModifierWBinding("Disabled", matchesKeyBinding, true), false);
assert.equal(isPrimaryModifierWBinding(null, matchesKeyBinding, true), false);
});
test("Cmd+W closes the active closable tab first", () => {
assert.deepEqual(
@@ -16,6 +25,92 @@ test("Cmd+W closes the active closable tab first", () => {
);
});
test("disabled close-tab binding forwards native Cmd+W to other custom shortcuts", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "s1",
editorTabIds: [],
sessionIds: ["s1"],
workspaceIds: [],
logViewIds: [],
closeTabShortcutEnabled: false,
}),
{ kind: "forwardShortcut" },
);
});
test("disabled close-tab binding forwards native Cmd+W while a dialog is open", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "s1",
editorTabIds: [],
sessionIds: ["s1"],
workspaceIds: [],
logViewIds: [],
closeTabShortcutEnabled: false,
hasOpenDialog: true,
}),
{ kind: "forwardShortcut" },
);
});
test("enabled close-tab binding closes the topmost dialog before the active tab", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "s1",
editorTabIds: [],
sessionIds: ["s1"],
workspaceIds: [],
logViewIds: [],
closeTabShortcutEnabled: true,
hasOpenDialog: true,
}),
{ kind: "closeDialog" },
);
});
test("disabled close-tab binding also forwards native Cmd+W on a log tab", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "log-1",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: ["log-1"],
closeTabShortcutEnabled: false,
}),
{ kind: "forwardShortcut" },
);
});
test("disabled close-tab binding forwards native Cmd+W instead of closing the window", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: "vault",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
closeTabShortcutEnabled: false,
}),
{ kind: "forwardShortcut" },
);
});
test("disabled close-tab binding forwards native Cmd+W when nothing is active", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
activeTabId: null,
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
closeTabShortcutEnabled: false,
}),
{ kind: "forwardShortcut" },
);
});
test("Cmd+W on a log view closes the log view", () => {
assert.deepEqual(
resolveWindowCommandCloseIntent({
+34
View File
@@ -1,8 +1,30 @@
export type WindowCommandCloseIntent =
| { kind: 'forwardShortcut' }
| { kind: 'closeDialog' }
| { kind: 'closeTab' }
| { kind: 'closeLogView'; tabId: string }
| { kind: 'closeWindow' };
type KeyBindingMatcher = (event: KeyboardEvent, binding: string, isMac: boolean) => boolean;
export function isPrimaryModifierWBinding(
binding: string | null,
matcher: KeyBindingMatcher,
isMac: boolean,
): boolean {
return Boolean(
binding
&& matcher({
key: 'w',
code: 'KeyW',
metaKey: isMac,
ctrlKey: !isMac,
altKey: false,
shiftKey: false,
} as KeyboardEvent, binding, isMac),
);
}
interface ResolveWindowCommandCloseIntentInput {
activeTabId: string | null;
editorTabIds: string[];
@@ -10,6 +32,8 @@ interface ResolveWindowCommandCloseIntentInput {
workspaceIds: string[];
logViewIds: string[];
pluginViewTabIds?: string[];
closeTabShortcutEnabled?: boolean;
hasOpenDialog?: boolean;
}
export function resolveWindowCommandCloseIntent({
@@ -19,7 +43,17 @@ export function resolveWindowCommandCloseIntent({
workspaceIds,
logViewIds,
pluginViewTabIds = [],
closeTabShortcutEnabled = true,
hasOpenDialog = false,
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
if (!closeTabShortcutEnabled) {
return { kind: 'forwardShortcut' };
}
if (hasOpenDialog) {
return { kind: 'closeDialog' };
}
if (!activeTabId) {
return { kind: 'closeWindow' };
}
+34
View File
@@ -231,9 +231,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_THINKING_MAP, JSON.stringify({ catty: "high" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
@@ -251,9 +253,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
defaultAgentId: "codex",
commandBlocklist: ["rm -rf"],
commandTimeout: 120,
responseIdleTimeout: 600,
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
agentThinkingMap: { catty: "high" },
webSearchConfig: webSearchWithoutKey,
showTerminalSelectionAction: false,
});
@@ -267,6 +271,14 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
);
});
test("AI response wait time is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
),
);
});
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
@@ -445,6 +457,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
defaultAgentId: "claude",
commandBlocklist: ["shutdown"],
commandTimeout: 30,
responseIdleTimeout: 900,
maxIterations: 5,
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
@@ -465,6 +478,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
@@ -1285,6 +1299,26 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ disconnectedNoticeMode: "dialog" }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.disconnectedNoticeMode, "dialog");
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ disconnectedNoticeMode: "terminal" }),
);
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.disconnectedNoticeMode, "dialog");
});
test("applySyncPayload restores the terminal host information bar preference", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
+18 -2
View File
@@ -99,9 +99,11 @@ import {
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
@@ -249,8 +251,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
'autoCloseOnExit',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
@@ -310,9 +312,11 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
@@ -556,12 +560,18 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
if (responseIdleTimeout != null && Number.isFinite(responseIdleTimeout)) {
ai.responseIdleTimeout = responseIdleTimeout;
}
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (agentModelMap) ai.agentModelMap = agentModelMap;
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const agentThinkingMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP);
if (agentThinkingMap) ai.agentThinkingMap = agentThinkingMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
@@ -793,9 +803,13 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.responseIdleTimeout != null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
}
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
if (ai.agentThinkingMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, ai.agentThinkingMap);
if (ai.webSearchConfig !== undefined) {
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
@@ -845,8 +859,10 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (ai.responseIdleTimeout != null) touched.push(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (ai.agentThinkingMap != null) touched.push(STORAGE_KEY_AI_AGENT_THINKING_MAP);
// agentProviderMap is *always* potentially mutated because the reconcile
// step may have pruned it even if the payload didn't ship one.
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
+8 -1
View File
@@ -72,7 +72,9 @@ interface AIChatPanelContentProps {
cattyConfiguredProviders: ProviderConfig[];
effectiveActiveProvider?: ProviderConfig;
effectiveActiveModelId?: string;
handleAgentProviderModelSelect: (providerId: string, modelId: string) => void;
handleAgentProviderModelSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
selectedCattyThinking?: string;
handleCattyThinkingSelect?: (level: string) => void;
files: UploadedFile[];
addFiles: (inputFiles: File[]) => Promise<void>;
removeFile: (fileId: string) => void;
@@ -140,6 +142,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
effectiveActiveProvider,
effectiveActiveModelId,
handleAgentProviderModelSelect,
selectedCattyThinking,
handleCattyThinkingSelect,
files,
addFiles,
removeFile,
@@ -350,6 +354,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={providerSwitcher}
pickerScope={currentAgentId}
thinkingLevel={currentAgentId === 'catty' ? selectedCattyThinking : undefined}
onThinkingLevelChange={currentAgentId === 'catty' ? handleCattyThinkingSelect : undefined}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
@@ -102,6 +102,8 @@ const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePan
setAgentModel: () => undefined,
agentProviderMap: {},
setAgentProvider: () => undefined,
agentThinkingMap: {},
setAgentThinking: () => undefined,
globalPermissionMode: 'auto',
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
@@ -277,6 +279,18 @@ test('AI side panel re-renders when command timeout changes', () => {
), false);
});
test('AI side panel re-renders when response wait time changes', () => {
const props = baseProps({
isVisible: true,
responseIdleTimeout: 120,
});
assert.equal(aiChatSidePanelPropsAreEqual(
props,
{ ...props, responseIdleTimeout: 600 },
), false);
});
test('AI side panel skips re-render when only a sibling scope session object changes', () => {
const own = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
const siblingA = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
+78 -13
View File
@@ -39,6 +39,7 @@ import {
tryBeginSendForKey,
} from './ai/draftSendGate';
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
@@ -67,6 +68,9 @@ import {
buildSdkRuntimeModelCacheKey,
sdkRuntimeModelCache,
generateId,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
@@ -271,10 +275,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setAgentModel,
agentProviderMap,
setAgentProvider,
agentThinkingMap,
setAgentThinking,
updateProvider,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
commandTimeout,
responseIdleTimeout,
maxIterations = 20,
webSearchConfig,
quickMessages = [],
@@ -760,12 +768,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
const providersRef = useRef(providers);
providersRef.current = providers;
const handleAgentProviderModelSelect = useCallback(
(providerId: string, modelId: string) => {
(providerId: string, modelId: string, contextWindow?: number) => {
setAgentProvider(currentAgentId, providerId);
setAgentModel(currentAgentId, modelId);
const sanitized = sanitizeContextWindow(contextWindow);
if (!updateProvider || sanitized == null) return;
const provider = providersRef.current.find((item) => item.id === providerId);
if (!provider) return;
if (provider.modelContextWindows?.[modelId] === sanitized) return;
updateProvider(providerId, {
modelContextWindows: {
...(provider.modelContextWindows ?? {}),
[modelId]: sanitized,
},
});
},
[currentAgentId, setAgentProvider, setAgentModel],
[currentAgentId, setAgentProvider, setAgentModel, updateProvider],
);
const providerDisplayName = effectiveActiveProvider?.name ?? '';
@@ -833,12 +855,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
};
}, []);
const externalAgentsRef = useRef(externalAgents);
externalAgentsRef.current = externalAgents;
const applySdkRuntimeModelCatalog = useCallback((
agentId: string,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
);
const storedModelId = agentModelMapRef.current[agentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
@@ -847,10 +876,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return rest;
});
} else {
setRuntimeAgentModelPresets((prev) => ({
...prev,
[agentId]: runtimePresets,
}));
setRuntimeAgentModelPresets((prev) => (
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
? prev
: { ...prev, [agentId]: runtimePresets }
));
}
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
storedModelId,
runtimePresets,
);
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
setAgentModel(agentId, normalizedStoredModelId);
return;
}
if (
@@ -988,7 +1027,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
return stored;
return normalizeStoredAgentModelSelection(stored, agentModelPresets) ?? stored;
}
if (agentModelPresets.length > 0) {
// Cursor CLI login defaults to `auto` (subscription quota routing).
@@ -1013,6 +1052,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
const selectedCattyThinking = agentThinkingMap.catty;
const handleCattyThinkingSelect = useCallback((level: string) => {
setAgentThinking('catty', level);
}, [setAgentThinking]);
const handleNewChat = useCallback(() => {
clearScopeDraft();
@@ -1148,7 +1192,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
if (catalog) {
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(
currentAgentConfig.command,
getExternalAgentSdkBackend(currentAgentConfig),
),
);
const storedModelId = agentModelMapRef.current[sendAgentId];
if (
catalog.currentModelId
@@ -1156,9 +1206,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
sendSelectedAgentModel = catalog.currentModelId;
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
sendSelectedAgentModel = storedModelId ?? sendSelectedAgentModel;
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
storedModelId,
runtimePresets,
) ?? storedModelId ?? sendSelectedAgentModel;
} else if (runtimePresets[0]) {
sendSelectedAgentModel = runtimePresets[0].id;
sendSelectedAgentModel = resolveAgentModelSelection(runtimePresets[0]);
}
}
}
@@ -1293,12 +1346,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider: sendActiveProvider,
activeModelId: sendActiveModelId,
reasoningEffort: selectedCattyThinking,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode: sendPermissionMode,
commandBlocklist,
commandTimeout,
responseIdleTimeout,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
@@ -1315,13 +1370,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, responseIdleTimeout, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
@@ -1357,12 +1412,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
{
activeProvider: effectiveActiveProvider,
activeModelId: effectiveActiveModelId,
reasoningEffort: selectedCattyThinking,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
commandTimeout,
responseIdleTimeout,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope({
@@ -1387,9 +1444,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
buildExecutorContextForScope,
commandBlocklist,
commandTimeout,
responseIdleTimeout,
currentAgentId,
effectiveActiveModelId,
effectiveActiveProvider,
selectedCattyThinking,
globalPermissionMode,
isStreaming,
scopeKey,
@@ -1642,6 +1701,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
selectedCattyThinking={selectedCattyThinking}
handleCattyThinkingSelect={handleCattyThinkingSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
@@ -1699,10 +1760,14 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'setAgentModel',
'agentProviderMap',
'setAgentProvider',
'agentThinkingMap',
'setAgentThinking',
'updateProvider',
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
'commandTimeout',
'responseIdleTimeout',
'maxIterations',
'webSearchConfig',
'quickMessages',
+4
View File
@@ -68,12 +68,16 @@ export interface AIChatSidePanelProps {
setAgentModel: (agentId: string, modelId: string) => void;
agentProviderMap: Record<string, string>;
setAgentProvider: (agentId: string, providerId: string) => void;
agentThinkingMap: Record<string, string>;
setAgentThinking: (agentId: string, thinkingLevel: string) => void;
updateProvider?: (id: string, updates: Partial<ProviderConfig>) => void;
// Safety
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
commandBlocklist?: string[];
commandTimeout?: number;
responseIdleTimeout?: number;
maxIterations?: number;
// Web search
@@ -4,8 +4,12 @@ import assert from 'node:assert/strict';
import {
buildCursorListModelsAgentEnv,
buildSdkRuntimeModelCacheKey,
canonicalizeEffortEncodedModelId,
createSdkRuntimeModelCache,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
modelPresetsContainId,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
@@ -66,6 +70,46 @@ test('Cursor auth mode changes invalidate the SDK model cache key', () => {
assert.notEqual(apiKey, cliLogin);
});
test('canonicalizeEffortEncodedModelId collapses Cursor query-string effort', () => {
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low'), 'gpt-5/low');
assert.equal(canonicalizeEffortEncodedModelId('gpt-5/high'), 'gpt-5/high');
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low&mode=fast'), 'gpt-5?effort=low&mode=fast');
});
test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () => {
const merged = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'auto', name: 'Auto' }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(merged[0]?.thinkingLevels, ['low', 'medium', 'high']);
assert.equal(merged[0]?.defaultThinkingLevel, 'medium');
assert.equal(merged[1]?.thinkingLevels, undefined);
const partial = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
assert.equal(
mergeFallbackThinkingLevels(
alreadyFilled,
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'] }],
),
alreadyFilled,
);
});
test('agentModelPresetsShallowEqual ignores array identity when contents match', () => {
const left = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
const right = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
assert.equal(agentModelPresetsShallowEqual(left, right), true);
assert.equal(
agentModelPresetsShallowEqual(left, [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['high'] }]),
false,
);
});
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
const presets: AgentModelPreset[] = [
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
@@ -73,6 +117,7 @@ test('modelPresetsContainId matches plain and thinking-level model ids', () => {
];
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
assert.equal(modelPresetsContainId(presets, 'gpt-5.5?effort=high'), true);
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
});
@@ -125,6 +170,55 @@ test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is retu
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
});
test('normalizeStoredAgentModelSelection rewrites Cursor query effort but not extra params', () => {
const presets: AgentModelPreset[] = [{
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: ['low', 'medium', 'high'],
defaultThinkingLevel: 'medium',
}];
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5?effort=low', presets), 'gpt-5.5/low');
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5/high', presets), 'gpt-5.5/high');
assert.equal(
normalizeStoredAgentModelSelection('gpt-5.5?effort=low&mode=fast', presets),
undefined,
);
});
test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', () => {
const presets: AgentModelPreset[] = [{
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}];
assert.equal(normalizeStoredAgentModelSelection('glm-5.1', presets), 'glm-5.1');
assert.equal(normalizeStoredAgentModelSelection('glm-5.1/high', presets), 'glm-5.1/high');
});
test('legacy plain model selections keep their model and gain its default reasoning effort', () => {
const presets: AgentModelPreset[] = [{
id: 'grok-4.5',
name: 'Grok 4.5',
thinkingLevels: ['high', 'medium', 'low'],
defaultThinkingLevel: 'high',
}, {
id: 'grok-4.6',
name: 'Grok 4.6',
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
defaultThinkingLevel: 'high',
}];
assert.equal(modelPresetsContainId(presets, 'grok-4.5'), true);
assert.equal(normalizeStoredAgentModelSelection('grok-4.5', presets), 'grok-4.5/high');
assert.equal(normalizeStoredAgentModelSelection('grok-4.5/medium', presets), 'grok-4.5/medium');
assert.equal(
shouldAdoptSdkCurrentModel('grok-4.6/high', 'grok-4.5', presets),
false,
);
});
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
{ id: 'custom/provider-model', name: 'custom/provider-model' },
+68 -3
View File
@@ -1,5 +1,12 @@
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
import {
resolveAgentModelSelection,
type AgentModelPreset,
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
export { canonicalizeEffortEncodedModelId };
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
@@ -175,17 +182,75 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export function mergeFallbackThinkingLevels(
runtime: AgentModelPreset[],
fallbacks: AgentModelPreset[],
): AgentModelPreset[] {
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels?.length) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
return {
...preset,
thinkingLevels: [...fallback.thinkingLevels],
...(fallback.defaultThinkingLevel
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
: {}),
...(fallback.encodeDefaultThinking === false
? { encodeDefaultThinking: false }
: {}),
};
});
return changed ? next : runtime;
}
export function agentModelPresetsShallowEqual(
left: AgentModelPreset[] | undefined,
right: AgentModelPreset[] | undefined,
): boolean {
if (left === right) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((preset, index) => {
const other = right[index];
return (
preset.id === other.id
&& preset.name === other.name
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
);
});
}
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
const canonical = canonicalizeEffortEncodedModelId(modelId);
if (preset.thinkingLevels?.length) {
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
return preset.id === canonical
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
}
return preset.id === modelId;
return preset.id === canonical;
}
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function normalizeStoredAgentModelSelection(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
): string | undefined {
if (!storedModelId) return undefined;
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
if (!preset) return undefined;
return canonical === preset.id
? resolveAgentModelSelection(preset)
: canonical;
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
shouldDeferExternalActionWhileAppLocked,
shouldNotifyAppLockGateRendererReady,
shouldRenderAppLockGateChildren,
} from "./AppLockGate.tsx";
test("shouldRenderAppLockGateChildren withholds startup-locked route children until unlock", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "startup",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: false,
lockReason: null,
hasRenderedChildren: false,
}),
true,
);
});
test("shouldRenderAppLockGateChildren force-renders terminal popup children before init", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: false,
locked: true,
lockReason: "startup",
hasRenderedChildren: false,
forceRenderChildren: true,
}),
true,
);
});
test("shouldRenderAppLockGateChildren withholds first mount for idle and background locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "idle",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "background",
hasRenderedChildren: false,
}),
false,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "manual",
hasRenderedChildren: false,
}),
false,
);
});
test("shouldRenderAppLockGateChildren withholds children before runtime initialization", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: false,
locked: false,
lockReason: null,
hasRenderedChildren: false,
}),
false,
);
});
test("shouldRenderAppLockGateChildren keeps mounted children for manual and idle locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "manual",
hasRenderedChildren: true,
}),
true,
);
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "idle",
hasRenderedChildren: true,
}),
true,
);
});
test("shouldRenderAppLockGateChildren keeps existing children mounted for reopen locks", () => {
assert.equal(
shouldRenderAppLockGateChildren({
initialized: true,
locked: true,
lockReason: "startup",
hasRenderedChildren: true,
}),
true,
);
});
test("shouldNotifyAppLockGateRendererReady waits until locked children can mount", () => {
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: true,
renderChildren: false,
}),
false,
);
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: true,
renderChildren: true,
}),
true,
);
assert.equal(
shouldNotifyAppLockGateRendererReady({
notifyRendererReady: false,
renderChildren: true,
}),
false,
);
});
test("shouldDeferExternalActionWhileAppLocked queues actions only while locked", () => {
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: true }), true);
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: false }), false);
});
+186
View File
@@ -0,0 +1,186 @@
import React, { useEffect, useRef, useState } from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { useAppLockBridge } from '../application/state/useAppLockBridge';
import { type AppLockReason, useAppLockState } from '../application/state/useAppLockState';
import { useSettingsState } from '../application/state/useSettingsState';
import { ToastProvider } from './ui/toast';
import { TooltipProvider } from './ui/tooltip';
import { AppLockOverlay } from './AppLockOverlay';
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
export interface AppLockGateRenderContext {
settings: SettingsState;
appLock: AppLockState;
}
interface AppLockGateProps {
children: (ctx: AppLockGateRenderContext) => React.ReactNode;
notifyRendererReady?: boolean;
/**
* Mount route children immediately (even before app-lock init / while locked).
* Terminal popups need this so their one-shot config IPC listeners register
* before main's post-loadURL send is only held in preload pending state —
* AppLockGate otherwise withholds children until async lock state resolves.
*/
forceRenderChildren?: boolean;
settingsOptions?: Parameters<typeof useSettingsState>[0];
}
interface AppLockGateDeps {
useSettingsState: typeof useSettingsState;
useAppLockState: typeof useAppLockState;
useAppLockBridge: typeof useAppLockBridge;
}
/**
* Withhold first content mount while the gate is locked for any reason
* (startup / idle / background / manual). A new renderer that reports ready
* before unlock would let main deliver ssh:// deep links into App handlers and
* start connections behind the lock overlay. Once children have mounted (e.g.
* idle re-lock after use), keep them mounted under the overlay.
*/
export function shouldRenderAppLockGateChildren(input: {
initialized: boolean;
locked: boolean;
lockReason: AppLockReason | null;
hasRenderedChildren: boolean;
forceRenderChildren?: boolean;
}): boolean {
if (input.forceRenderChildren) return true;
if (!input.initialized && !input.hasRenderedChildren) return false;
if (input.locked && !input.hasRenderedChildren) return false;
return true;
}
/**
* Deep-link delivery waits on rendererReady. Only mark the renderer ready once
* app children are allowed to mount, so unlock (not the 15s readiness timeout)
* is what unblocks pending ssh:// links after a locked first paint.
*/
export function shouldNotifyAppLockGateRendererReady(input: {
notifyRendererReady: boolean;
renderChildren: boolean;
}): boolean {
return input.notifyRendererReady && input.renderChildren;
}
/** Queue external actions (e.g. ssh:// deep links) while the app lock is held. */
export function shouldDeferExternalActionWhileAppLocked(input: {
locked: boolean;
}): boolean {
return input.locked === true;
}
export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGateProps> {
const AppLockGateImpl: React.FC<AppLockGateProps> = ({
children,
notifyRendererReady = true,
forceRenderChildren = false,
settingsOptions,
}) => {
const settings = deps.useSettingsState(settingsOptions);
const appLock = deps.useAppLockState(settings.appLockSettings);
const {
notifyRendererReady: notifyAppLockRendererReady,
onAppLockReopen,
} = deps.useAppLockBridge();
const hasRenderedChildrenRef = useRef(false);
const [reopenSignal, setReopenSignal] = useState(0);
const renderChildren = shouldRenderAppLockGateChildren({
initialized: appLock.initialized,
locked: appLock.locked,
lockReason: appLock.lockReason,
hasRenderedChildren: hasRenderedChildrenRef.current,
forceRenderChildren,
});
if (renderChildren) {
hasRenderedChildrenRef.current = true;
}
const shouldNotifyRendererReady = shouldNotifyAppLockGateRendererReady({
notifyRendererReady,
renderChildren,
});
useEffect(() => {
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
notifyAppLockRendererReady();
}
} catch {
// ignore
}
}, [notifyAppLockRendererReady, shouldNotifyRendererReady]);
useEffect(() => {
const unsubscribe = onAppLockReopen(() => {
setReopenSignal((current) => current + 1);
void appLock.resync?.();
});
return () => unsubscribe?.();
}, [appLock, onAppLockReopen]);
useEffect(() => {
const handleVisibilityOrFocus = () => {
void appLock.resync?.();
};
window.addEventListener('focus', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.removeEventListener('focus', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};
}, [appLock]);
// When re-locked after first mount, children stay mounted under the overlay.
// Mark them inert so pointer/keyboard/AT cannot reach underlying controls
// (sessions, editors, side panels) while the lock screen is up.
const lockedBackground = appLock.locked === true;
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
{renderChildren ? (
<div
// React 19 supports the HTML inert attribute; when true, the
// subtree is non-interactive and removed from sequential focus.
inert={lockedBackground ? true : undefined}
aria-hidden={lockedBackground || undefined}
className={lockedBackground ? 'pointer-events-none' : undefined}
data-app-lock-background={lockedBackground ? 'locked' : 'unlocked'}
>
{children({ settings, appLock })}
</div>
) : null}
<AppLockOverlay
locked={appLock.locked}
reason={appLock.lockReason}
onUnlock={appLock.unlock}
systemUnlockStatus={appLock.systemUnlockStatus}
onSystemUnlock={appLock.unlockWithSystemAuth}
onResetAppLock={appLock.reset}
autoPromptSystemUnlock={settings.appLockSettings.systemUnlockAutoPromptEnabled}
reopenSignal={reopenSignal}
/>
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);
};
return AppLockGateImpl;
}
export const AppLockGate = createAppLockGate({
useSettingsState,
useAppLockState,
useAppLockBridge,
});
+857
View File
@@ -0,0 +1,857 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
runWithAct,
} from "./test-support/renderReactDom.tsx";
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
await runWithAct(async () => {
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
});
await flushEffects();
await flushEffects();
}
test("AppLockOverlay shows incorrect-password error and clears it after editing", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const unlockAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async (password) => {
unlockAttempts.push(password);
return { ok: false, error: "incorrect" as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
const form = dom.document.querySelector("form");
const passwordLabel = dom.document.querySelector('label[for="app-lock-password"]');
const lockIcon = dom.document.querySelector('[data-testid="app-lock-password-lock-icon"]');
assert.ok(input);
assert.ok(form);
assert.ok(passwordLabel);
assert.ok(lockIcon);
assert.equal(input.getAttribute("placeholder"), "Enter password to unlock");
assert.match(passwordLabel.getAttribute("class") ?? "", /sr-only/);
assert.equal(lockIcon.getAttribute("aria-hidden"), "true");
assert.doesNotMatch(lockIcon.getAttribute("class") ?? "", /border-l/);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "wrong");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
await flushEffects();
await flushEffects();
assert.deepEqual(unlockAttempts, ["wrong"]);
assert.match(dom.document.body.textContent ?? "", /Incorrect lock password/i);
setInputValue.call(input, "wrong-again");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Incorrect lock password/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const resetAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async (currentPassword) => {
resetAttempts.push(currentPassword);
},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
assert.ok(input);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
assert.match(dom.document.body.textContent ?? "", /Reset App Lock/i);
const resetButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
assert.ok(resetButton);
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.deepEqual(resetAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay only reveals reset after five quick logo clicks", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => dom.window.setTimeout(resolve, 1600));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reset controls do not submit the unlock form", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let unlockCount = 0;
const resetAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => {
unlockCount += 1;
return { ok: false, error: "incorrect" as const };
},
onResetAppLock: async (currentPassword) => {
resetAttempts.push(currentPassword);
},
}),
),
);
await flushEffects();
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
assert.ok(input);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
assert.ok(logoButton);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
}
await flushEffects();
const cancelButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Cancel/i.test(button.textContent ?? ""));
assert.ok(cancelButton);
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.equal(unlockCount, 0);
assert.deepEqual(resetAttempts, []);
for (let index = 0; index < 5; index += 1) {
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
}
await flushEffects();
const resetButton = [...dom.document.querySelectorAll("button")]
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
assert.ok(resetButton);
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(unlockCount, 0);
assert.deepEqual(resetAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay renders platform-specific system unlock button", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-windows-icon"]'));
const button = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Windows Hello/i.test(candidate.textContent ?? ""));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay localizes the system unlock button label", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "zh-CN" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: null,
},
onSystemUnlock: async () => ({ ok: true as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /使用 Touch ID 解锁/i);
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-touch-id-icon"]'));
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay does not automatically request system unlock by default", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
const props = {
locked: true,
reason: "manual" as const,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
};
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.match(dom.document.body.textContent ?? "", /Use password instead/i);
const button = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(button);
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay reveals password fallback and cancels a delayed automatic prompt", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
autoPromptSystemUnlock: true,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.equal(dom.document.getElementById("app-lock-password"), null);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
const usePasswordButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Use password instead/i.test(candidate.textContent ?? ""));
assert.ok(usePasswordButton);
await dispatchDomEvent(usePasswordButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.ok(dom.document.getElementById("app-lock-password"));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 0);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay shows animated feedback while system unlock is in flight", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let resolveSystemUnlock: ((result: { ok: true }) => void) | null = null;
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => new Promise<{ ok: true }>((resolve) => {
resolveSystemUnlock = resolve;
}),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const systemButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(systemButton);
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /Waiting for Touch ID/i);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
assert.ok(resolveSystemUnlock);
await runWithAct(async () => {
resolveSystemUnlock?.({ ok: true });
});
await flushEffects();
await flushEffects();
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay automatically requests system unlock once when auto prompt is enabled", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
const props = {
locked: true,
reason: "manual" as const,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
};
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, props),
),
);
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay waits until the document is visible before auto system unlock", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
try {
Object.defineProperty(dom.document, "visibilityState", {
configurable: true,
value: "hidden",
});
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
Object.defineProperty(dom.document, "visibilityState", {
configurable: true,
value: "visible",
});
await dispatchDomEvent(dom.document, new dom.window.Event("visibilitychange"));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 0);
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay waits for window focus before auto system unlock", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let focused = false;
let systemUnlockCount = 0;
Object.defineProperty(dom.document, "hasFocus", {
configurable: true,
value: () => focused,
});
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
autoPromptSystemUnlock: true,
onSystemUnlock: async () => {
systemUnlockCount += 1;
return { ok: true as const };
},
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 0);
focused = true;
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay retries a reopen presentation after an in-flight auto prompt finishes", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let systemUnlockCount = 0;
let resolveFirstUnlock: ((result: { ok: false; error: "cancelled" }) => void) | null = null;
try {
const createOverlay = (reopenSignal: number) => React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "background",
reopenSignal,
autoPromptSystemUnlock: true,
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin" as const,
label: "Touch ID" as const,
reason: null,
},
onSystemUnlock: async () => {
systemUnlockCount += 1;
if (systemUnlockCount === 1) {
return new Promise<{ ok: false; error: "cancelled" }>((resolve) => {
resolveFirstUnlock = resolve;
});
}
return { ok: false as const, error: "cancelled" as const };
},
onResetAppLock: async () => {},
}),
);
await renderer.render(createOverlay(1));
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 1);
await renderer.render(createOverlay(2));
await flushEffects();
await flushEffects();
assert.equal(systemUnlockCount, 1);
assert.ok(resolveFirstUnlock);
await runWithAct(async () => {
resolveFirstUnlock?.({ ok: false, error: "cancelled" });
});
await flushEffects();
await flushEffects();
await waitForAutoPrompt(dom);
assert.equal(systemUnlockCount, 2);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay hides system unlock when unavailable", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
systemUnlockStatus: {
supported: true,
available: false,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: "unavailable",
},
onSystemUnlock: async () => ({ ok: true as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay keeps password fallback after system unlock failure", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const unlockAttempts: string[] = [];
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async (password) => {
unlockAttempts.push(password);
return { ok: true as const };
},
systemUnlockStatus: {
supported: true,
available: true,
enabled: true,
platform: "darwin",
label: "Touch ID",
reason: null,
},
onSystemUnlock: async () => ({ ok: false as const, error: "cancelled" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
assert.equal(dom.document.getElementById("app-lock-password"), null);
const systemButton = [...dom.document.querySelectorAll("button")]
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
assert.ok(systemButton);
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await flushEffects();
assert.match(dom.document.body.textContent ?? "", /System unlock was not completed/i);
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
const form = dom.document.querySelector("form");
assert.ok(input);
assert.ok(form);
const setInputValue = Object.getOwnPropertyDescriptor(
dom.window.HTMLInputElement.prototype,
"value",
)?.set;
assert.ok(setInputValue);
setInputValue.call(input, "secret");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
await flushEffects();
assert.deepEqual(unlockAttempts, ["secret"]);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
const form = overlay?.querySelector("form");
assert.ok(overlay);
assert.ok(form);
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
getAppLockErrorMessageKey,
} from "./AppLockOverlay.tsx";
test("getAppLockErrorMessageKey maps unlock errors to localized message keys", () => {
assert.equal(getAppLockErrorMessageKey("empty"), "appLock.error.emptyPassword");
assert.equal(getAppLockErrorMessageKey("incorrect"), "appLock.error.incorrectPassword");
assert.equal(getAppLockErrorMessageKey(null), null);
});
test("AppLockOverlay marks the backdrop as a window drag region", () => {
const source = readFileSync(new URL("./AppLockOverlay.tsx", import.meta.url), "utf8");
assert.match(
source,
/className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"/,
);
assert.match(
source,
/className="flex w-full max-w-\[360px\] flex-col items-center gap-5 app-no-drag"/,
);
});
+522
View File
@@ -0,0 +1,522 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Fingerprint, KeyRound, Loader2, LockKeyhole } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import type {
AppLockReason,
AppLockSystemUnlockResult,
AppLockSystemUnlockStatus,
AppLockUnlockResult,
} from '../application/state/useAppLockState';
import { AppLogo } from './AppLogo';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
const RESET_REVEAL_CLICK_COUNT = 5;
const RESET_REVEAL_WINDOW_MS = 1500;
export const APP_LOCK_AUTO_PROMPT_DELAY_MS = 1200;
function isWindowInteractive(): boolean {
return typeof document === 'undefined'
|| (
document.visibilityState !== 'hidden'
&& (typeof document.hasFocus !== 'function' || document.hasFocus())
);
}
interface AppLockOverlayProps {
locked: boolean;
reason: AppLockReason | null;
onUnlock: (password: string) => Promise<AppLockUnlockResult>;
systemUnlockStatus?: AppLockSystemUnlockStatus;
onSystemUnlock?: () => Promise<AppLockSystemUnlockResult>;
onResetAppLock: (currentPassword: string) => Promise<void>;
autoPromptSystemUnlock?: boolean;
reopenSignal?: number;
}
export function getAppLockErrorMessageKey(error: AppLockUnlockResult['error'] | null): string | null {
switch (error) {
case 'empty':
return 'appLock.error.emptyPassword';
case 'incorrect':
return 'appLock.error.incorrectPassword';
default:
return null;
}
}
export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
locked,
reason,
onUnlock,
systemUnlockStatus,
onSystemUnlock,
onResetAppLock,
autoPromptSystemUnlock = false,
reopenSignal = 0,
}) => {
const { t } = useI18n();
const passwordRef = useRef<HTMLInputElement>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState<AppLockUnlockResult['error'] | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSystemUnlocking, setIsSystemUnlocking] = useState(false);
const [isAutoPromptPending, setIsAutoPromptPending] = useState(false);
const [systemUnlockError, setSystemUnlockError] = useState(false);
const [passwordFallbackRequested, setPasswordFallbackRequested] = useState(false);
// Value is only read inside functional updates; keep the setter alone.
const [, setLogoClickCount] = useState(0);
const [lastLogoClickAt, setLastLogoClickAt] = useState<number | null>(null);
const [showReset, setShowReset] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [resetError, setResetError] = useState(false);
const [windowInteractive, setWindowInteractive] = useState(() => isWindowInteractive());
const lastAutoUnlockPresentationRef = useRef<string | null>(null);
const autoUnlockTimerRef = useRef<number | null>(null);
const canUseSystemUnlock = Boolean(
systemUnlockStatus?.enabled
&& systemUnlockStatus.available
&& systemUnlockStatus.label
&& onSystemUnlock,
);
const showPasswordUnlock = !canUseSystemUnlock
|| passwordFallbackRequested
|| systemUnlockError
|| showReset;
useEffect(() => {
const updateWindowInteractive = () => {
setWindowInteractive(isWindowInteractive());
};
updateWindowInteractive();
document.addEventListener('visibilitychange', updateWindowInteractive);
window.addEventListener('focus', updateWindowInteractive);
window.addEventListener('blur', updateWindowInteractive);
return () => {
document.removeEventListener('visibilitychange', updateWindowInteractive);
window.removeEventListener('focus', updateWindowInteractive);
window.removeEventListener('blur', updateWindowInteractive);
};
}, []);
const overlayRootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!locked) {
if (autoUnlockTimerRef.current !== null) {
window.clearTimeout(autoUnlockTimerRef.current);
autoUnlockTimerRef.current = null;
}
setPassword('');
setError(null);
setIsSubmitting(false);
setIsSystemUnlocking(false);
setIsAutoPromptPending(false);
setSystemUnlockError(false);
setPasswordFallbackRequested(false);
setLogoClickCount(0);
setLastLogoClickAt(null);
setShowReset(false);
setIsResetting(false);
setResetError(false);
lastAutoUnlockPresentationRef.current = null;
return;
}
if (!showPasswordUnlock) return;
const timeout = window.setTimeout(() => passwordRef.current?.focus(), 0);
return () => window.clearTimeout(timeout);
}, [locked, showPasswordUnlock]);
useEffect(() => {
if (!locked || reason !== 'background' || reopenSignal <= 0) return;
setPasswordFallbackRequested(false);
setSystemUnlockError(false);
}, [locked, reason, reopenSignal]);
// Soft focus trap + stop ALL app-level keydown while locked.
// inert does not block window capture (shortkey/hotkey recorders) or window
// bubble (Snippets Ctrl/Cmd+S). Use window capture + stopImmediate so peer
// window handlers never run, without stopPropagation so the lock password
// input still receives the event. Document bubble then stops before window
// bubble listeners (Codex P2 on 81e1f779).
useEffect(() => {
if (!locked) return;
const onWindowCapture = (event: KeyboardEvent) => {
// Suppress other window-level keydown handlers registered after us.
event.stopImmediatePropagation();
if (event.key !== 'Tab') return;
const root = overlayRootRef.current;
if (!root) return;
const focusable = root.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), [href], select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement as HTMLElement | null;
if (!active || !root.contains(active)) {
event.preventDefault();
first.focus();
return;
}
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
// Unconditional: even when focus is on body, window bubble shortcuts must
// not fire. Runs on document bubble before window bubble.
const onDocumentBubble = (event: KeyboardEvent) => {
event.stopPropagation();
};
window.addEventListener('keydown', onWindowCapture, true);
document.addEventListener('keydown', onDocumentBubble, false);
return () => {
window.removeEventListener('keydown', onWindowCapture, true);
document.removeEventListener('keydown', onDocumentBubble, false);
};
}, [locked]);
// Context menus portal at z-index max (2147483647). Hide it while locked so
// open right-click menus cannot sit above the lock screen (Codex P2).
useEffect(() => {
if (!locked) return;
const portal = document.getElementById('netcatty-context-menu-root');
if (!portal) return;
const prev = portal.style.visibility;
portal.style.visibility = 'hidden';
portal.style.pointerEvents = 'none';
return () => {
portal.style.visibility = prev;
portal.style.pointerEvents = 'none'; // portal default is none; children re-enable
};
}, [locked]);
const clearAutoUnlockTimer = useCallback(() => {
if (autoUnlockTimerRef.current !== null) {
window.clearTimeout(autoUnlockTimerRef.current);
autoUnlockTimerRef.current = null;
}
setIsAutoPromptPending(false);
}, []);
const handleSystemUnlock = useCallback(async () => {
if (isSystemUnlocking || !onSystemUnlock) return;
clearAutoUnlockTimer();
setIsSystemUnlocking(true);
setSystemUnlockError(false);
try {
const result = await onSystemUnlock();
if (!result.ok) {
setSystemUnlockError(true);
setPasswordFallbackRequested(true);
}
} catch {
setSystemUnlockError(true);
setPasswordFallbackRequested(true);
} finally {
setIsSystemUnlocking(false);
}
}, [clearAutoUnlockTimer, isSystemUnlocking, onSystemUnlock]);
const requestAutomaticSystemUnlock = useCallback(() => {
if (isSystemUnlocking || !onSystemUnlock) return false;
void handleSystemUnlock();
return true;
}, [handleSystemUnlock, isSystemUnlocking, onSystemUnlock]);
useEffect(() => {
if (!locked) return;
if (!autoPromptSystemUnlock) return;
if (!windowInteractive) return;
if (showReset) return;
if (isSystemUnlocking) return;
if (!systemUnlockStatus?.enabled || !systemUnlockStatus.available || !systemUnlockStatus.label) return;
if (!onSystemUnlock) return;
const presentationKey = reason === 'background'
? `background:${reopenSignal}`
: `foreground:${reason ?? 'default'}:${locked}`;
if (reason === 'background' && reopenSignal <= 0) return;
if (
passwordFallbackRequested
&& lastAutoUnlockPresentationRef.current === presentationKey
) return;
if (lastAutoUnlockPresentationRef.current === presentationKey) return;
if (autoUnlockTimerRef.current !== null) return;
if (passwordFallbackRequested || systemUnlockError) {
setPasswordFallbackRequested(false);
setSystemUnlockError(false);
}
setIsAutoPromptPending(true);
const timer = window.setTimeout(() => {
if (autoUnlockTimerRef.current !== timer) return;
autoUnlockTimerRef.current = null;
setIsAutoPromptPending(false);
if (!requestAutomaticSystemUnlock()) return;
lastAutoUnlockPresentationRef.current = presentationKey;
}, APP_LOCK_AUTO_PROMPT_DELAY_MS);
autoUnlockTimerRef.current = timer;
return () => {
if (autoUnlockTimerRef.current !== timer) return;
window.clearTimeout(timer);
autoUnlockTimerRef.current = null;
setIsAutoPromptPending(false);
};
}, [
locked,
autoPromptSystemUnlock,
reason,
reopenSignal,
windowInteractive,
onSystemUnlock,
systemUnlockStatus?.available,
systemUnlockStatus?.enabled,
systemUnlockStatus?.label,
passwordFallbackRequested,
systemUnlockError,
showReset,
isSystemUnlocking,
requestAutomaticSystemUnlock,
]);
if (!locked) return null;
const errorKey = getAppLockErrorMessageKey(error);
const SystemUnlockIcon = systemUnlockStatus?.platform === 'darwin' ? Fingerprint : KeyRound;
const isSystemButtonLoading = isAutoPromptPending || isSystemUnlocking;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
const result = await onUnlock(password);
if (!result.ok) {
setError(result.error);
setIsSubmitting(false);
return;
}
setPassword('');
setIsSubmitting(false);
};
const handleLogoClick = () => {
const now = Date.now();
setLogoClickCount((current) => {
const isQuickSequence = lastLogoClickAt !== null && now - lastLogoClickAt <= RESET_REVEAL_WINDOW_MS;
const nextCount = isQuickSequence ? current + 1 : 1;
if (nextCount >= RESET_REVEAL_CLICK_COUNT) {
setShowReset(true);
}
return nextCount;
});
setLastLogoClickAt(now);
};
const handleReset = async () => {
if (isResetting) return;
setIsResetting(true);
setResetError(false);
try {
await onResetAppLock(password);
} catch {
setResetError(true);
setIsResetting(false);
}
};
return (
// Overlay covers the title bar, and is the only chrome on a locked first
// paint, so the backdrop owns the window drag region.
<div
ref={overlayRootRef}
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"
style={{ zIndex: 2147483647 }}
role="dialog"
data-state="open"
data-app-lock-overlay=""
aria-modal="true"
aria-labelledby="app-lock-title"
>
<form
className="flex w-full max-w-[360px] flex-col items-center gap-5 app-no-drag"
onSubmit={handleSubmit}
>
<button
type="button"
className="flex h-24 w-24 items-center justify-center border-0 bg-transparent p-0 shadow-none transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={t('appLock.logoLabel')}
data-testid="app-lock-logo-easter-egg"
onClick={handleLogoClick}
>
<AppLogo className="h-full w-full" />
</button>
<h1 id="app-lock-title" className="text-center text-xl font-semibold tracking-normal">
{t('appLock.title')}
</h1>
{showPasswordUnlock && (
<>
<div className="w-full space-y-2">
<Label htmlFor="app-lock-password" className="sr-only">
{t('appLock.passwordLabel')}
</Label>
<div className="relative">
<Input
ref={passwordRef}
id="app-lock-password"
type="password"
value={password}
autoComplete="current-password"
placeholder={t('appLock.passwordPlaceholder')}
disabled={isSubmitting}
aria-invalid={Boolean(errorKey)}
aria-describedby={errorKey ? 'app-lock-error' : undefined}
className="pr-12"
onChange={(event) => {
setPassword(event.target.value);
if (error) setError(null);
}}
/>
<span
className="pointer-events-none absolute inset-y-px right-px flex w-10 items-center justify-center rounded-r-md bg-muted/30 text-muted-foreground"
data-testid="app-lock-password-lock-icon"
aria-hidden="true"
>
<LockKeyhole className="h-4 w-4" strokeWidth={2.25} />
</span>
</div>
{errorKey && (
<p id="app-lock-error" className="text-sm text-destructive">
{t(errorKey)}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? t('appLock.unlocking') : t('appLock.unlock')}
</Button>
</>
)}
{canUseSystemUnlock && systemUnlockStatus?.label && (
<div className="w-full space-y-2">
<Button
type="button"
variant={showPasswordUnlock ? 'outline' : 'default'}
className="w-full"
disabled={isSystemButtonLoading}
data-testid="app-lock-system-unlock-button"
onClick={() => void handleSystemUnlock()}
>
{isSystemButtonLoading ? (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
data-testid="app-lock-system-unlock-loading"
aria-hidden="true"
/>
) : (
<SystemUnlockIcon
className="mr-2 h-4 w-4"
data-testid={systemUnlockStatus.platform === 'darwin'
? 'app-lock-system-unlock-touch-id-icon'
: 'app-lock-system-unlock-windows-icon'}
aria-hidden="true"
/>
)}
<span aria-live="polite">
{isAutoPromptPending
? t('appLock.systemUnlock.preparing').replace('{label}', systemUnlockStatus.label)
: isSystemUnlocking
? t('appLock.systemUnlock.verifying').replace('{label}', systemUnlockStatus.label)
: t('appLock.systemUnlock.unlockWith').replace('{label}', systemUnlockStatus.label)}
</span>
</Button>
{!showPasswordUnlock && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto w-full py-1 text-muted-foreground"
onClick={() => {
lastAutoUnlockPresentationRef.current = reason === 'background'
? `background:${reopenSignal}`
: `foreground:${reason ?? 'default'}:${locked}`;
clearAutoUnlockTimer();
setPasswordFallbackRequested(true);
setSystemUnlockError(false);
}}
>
{t('appLock.usePassword')}
</Button>
)}
{systemUnlockError && (
<p className="text-sm text-destructive">
{t('appLock.systemUnlock.error')}
</p>
)}
</div>
)}
{showReset && (
<div
className="w-full rounded-lg border border-border/70 bg-card p-3 text-left shadow-sm"
role="region"
aria-live="polite"
>
<div className="space-y-1">
<p className="text-sm font-medium">{t('appLock.reset.title')}</p>
<p className="text-xs leading-5 text-muted-foreground">
{t('appLock.reset.description')}
</p>
</div>
{resetError && (
<p className="mt-2 text-xs text-destructive">
{t('appLock.reset.error')}
</p>
)}
<div className="mt-3 flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
disabled={isResetting}
onClick={() => {
setShowReset(false);
setLogoClickCount(0);
setLastLogoClickAt(null);
setResetError(false);
}}
>
{t('appLock.reset.cancel')}
</Button>
<Button
type="button"
variant="destructive"
className="flex-1"
disabled={isResetting}
onClick={handleReset}
>
{isResetting ? t('appLock.reset.resetting') : t('appLock.reset.confirm')}
</Button>
</div>
</div>
)}
</form>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
function readComponentSource(path: string) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
test("settings, tray, and terminal popup routes do not create fallback settings owners", () => {
const settingsPage = readComponentSource("./SettingsPage.tsx");
const trayPanel = readComponentSource("./TrayPanel.tsx");
const terminalPopupPage = readComponentSource("./TerminalPopupPage.tsx");
assert.doesNotMatch(settingsPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(settingsPage, /settings\?:\s*SettingsState/);
assert.doesNotMatch(settingsPage, /providedSettings\s*\?\?/);
assert.doesNotMatch(trayPanel, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(trayPanel, /settings\?:\s*SettingsState/);
assert.doesNotMatch(trayPanel, /providedSettings\s*\?\?/);
assert.doesNotMatch(terminalPopupPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
assert.doesNotMatch(terminalPopupPage, /settings\?:\s*SettingsState/);
assert.doesNotMatch(terminalPopupPage, /providedSettings\s*\?\?/);
});
test("terminal popups wait for app-lock initialization before starting a terminal", () => {
const indexSource = readComponentSource("../index.tsx");
assert.match(
indexSource,
/allowTerminalStart=\{appLock\.initialized\s*&&\s*!appLock\.locked\}/,
);
});
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
} from "./test-support/renderReactDom.tsx";
test("associates the transfer-center folder warning with every destructive Replace action", async (t) => {
const env = installDomEnvironment();
const previousMutationObserver = globalThis.MutationObserver;
const previousNodeFilter = globalThis.NodeFilter;
const previousHTMLInputElement = globalThis.HTMLInputElement;
const previousElement = globalThis.Element;
const previousRequestAnimationFrame = globalThis.requestAnimationFrame;
const previousCancelAnimationFrame = globalThis.cancelAnimationFrame;
Object.defineProperties(globalThis, {
MutationObserver: { configurable: true, writable: true, value: env.window.MutationObserver },
NodeFilter: { configurable: true, writable: true, value: env.window.NodeFilter },
HTMLInputElement: { configurable: true, writable: true, value: env.window.HTMLInputElement },
Element: { configurable: true, writable: true, value: env.window.Element },
requestAnimationFrame: {
configurable: true,
writable: true,
value: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
},
cancelAnimationFrame: { configurable: true, writable: true, value: clearTimeout },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
const taskId = "accessible-folder-conflict";
const unregisterOwner = sftpTransferCenterStore.registerOwner("accessibility-test-owner", {
pause: () => {},
resume: () => {},
cancel: () => {},
retry: () => {},
prioritize: () => {},
dismiss: () => {},
ownsTask: () => true,
resolveConflict: () => {},
});
sftpTransferCenterStore.publishOwner("accessibility-test-owner", [{
id: taskId,
fileName: "docs",
sourcePath: "/source/docs",
targetPath: "/destination/docs",
sourceConnectionId: "source",
targetConnectionId: "target",
direction: "remote-to-remote",
status: "attention",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
conflict: {
transferId: taskId,
fileName: "docs",
sourcePath: "/source/docs",
targetPath: "/destination/docs",
isDirectory: true,
existingType: "directory",
applyToAllCount: 2,
existingSize: 0,
newSize: 0,
existingModified: 1,
newModified: 2,
},
}]);
t.after(async () => {
await renderer.unmount();
sftpTransferCenterStore.dismiss(taskId);
unregisterOwner();
await new Promise((resolve) => setTimeout(resolve, 50));
Object.defineProperties(globalThis, {
MutationObserver: { configurable: true, writable: true, value: previousMutationObserver },
NodeFilter: { configurable: true, writable: true, value: previousNodeFilter },
HTMLInputElement: { configurable: true, writable: true, value: previousHTMLInputElement },
Element: { configurable: true, writable: true, value: previousElement },
requestAnimationFrame: { configurable: true, writable: true, value: previousRequestAnimationFrame },
cancelAnimationFrame: { configurable: true, writable: true, value: previousCancelAnimationFrame },
});
env.cleanup();
});
await renderer.render(React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TooltipProvider, null, React.createElement(GlobalSftpTransferCenter)),
));
await flushEffects();
const toggle = env.document.querySelector<HTMLButtonElement>("[data-section=global-sftp-transfer-toggle]");
assert.ok(toggle, "transfer-center toggle should render");
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
const replaceButtons = Array.from(env.document.querySelectorAll<HTMLButtonElement>("button"))
.filter((button) => button.textContent?.startsWith("Replace"));
assert.equal(replaceButtons.length, 2);
for (const button of replaceButtons) {
const describedBy = button.getAttribute("aria-describedby");
assert.ok(describedBy, `${button.textContent} should expose the folder deletion warning`);
const warning = env.document.getElementById(describedBy);
assert.match(warning?.textContent ?? "", /Replace deletes all destination files and sub-folders/);
}
});
@@ -4,6 +4,7 @@ import test from "node:test";
import type { TransferTask } from "../domain/models";
import {
buildGlobalTransferProgressDisplay,
getGlobalConflictActionPresentation,
getGlobalTransferBadge,
getGlobalTransferBatchEligibility,
getGlobalTransferStatusOverride,
@@ -16,6 +17,21 @@ import {
splitBackgroundTransfers,
} from "./GlobalSftpTransferCenter";
test("folder conflicts emphasize merge and mark replace as destructive in the transfer center", () => {
assert.deepEqual(getGlobalConflictActionPresentation("merge", true), {
variant: "default",
destructiveReplace: false,
});
assert.deepEqual(getGlobalConflictActionPresentation("replace", true), {
variant: "outline",
destructiveReplace: true,
});
assert.deepEqual(getGlobalConflictActionPresentation("replace", false), {
variant: "default",
destructiveReplace: false,
});
});
const task = (id: string, status: TransferTask["status"], background = false) => ({
id,
fileName: id,
+58 -19
View File
@@ -1,5 +1,6 @@
import {
AlertCircle,
AlertTriangle,
ArrowDownToLine,
ArrowDownUp,
ArrowUpFromLine,
@@ -26,7 +27,7 @@ import {
import { transferRuntime } from "../application/state/sftp/transferRuntime";
import { useGlobalSftpTransferActions } from "../application/state/useGlobalSftpTransferActions";
export { getGlobalTransferBatchEligibility } from "../domain/sftpTransferActions";
import type { TransferTask } from "../domain/models";
import type { FileConflictAction, TransferTask } from "../domain/models";
import { canReplaceSftpConflict } from "../domain/sftpConflict";
import { estimateTransferEtaSeconds, formatFileSize, formatTransferEta } from "../application/state/sftp/utils";
import { cn } from "../lib/utils";
@@ -113,6 +114,18 @@ export function getGlobalTransferStatusOverride(task: Pick<TransferTask, "error"
return task.error || task.pauseUnavailableReason;
}
export function getGlobalConflictActionPresentation(
action: FileConflictAction,
destructiveDirectoryReplace: boolean,
) {
const safeMerge = destructiveDirectoryReplace && action === "merge";
const destructiveReplace = destructiveDirectoryReplace && action === "replace";
return {
variant: safeMerge || (!destructiveDirectoryReplace && action === "replace") ? "default" : "outline",
destructiveReplace,
} as const;
}
const BUCKETS: readonly GlobalTransferBucket[] = ["all", "active", "queued", "paused", "attention", "completed"];
export function getTasksForGlobalTransferBucket(
@@ -334,6 +347,7 @@ function TransferRow({
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
const isDirParent = isDirectoryParentTask(task);
@@ -671,6 +685,8 @@ function TransferRow({
{task.status === "attention" && task.conflict && canControl && (() => {
const conflict = task.conflict!;
const canMerge = conflict.isDirectory && conflict.existingType === "directory";
const destructiveDirectoryReplace = canMerge;
const unresolvedFolderType = conflict.isDirectory && !conflict.existingType;
const canReplace = canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
const actions = [
"stop",
@@ -685,30 +701,53 @@ function TransferRow({
...(canMerge ? (["merge"] as const) : []),
...(canReplace ? (["replace"] as const) : []),
] as const;
return (
<div className="mt-2 flex flex-wrap justify-end gap-1">
{actions.map((action) => (
const renderConflictAction = (action: FileConflictAction, applyToAll = false) => {
const presentation = getGlobalConflictActionPresentation(action, destructiveDirectoryReplace);
return (
<Button
key={action}
variant={action === "replace" ? "default" : "outline"}
key={applyToAll ? `all-${action}` : action}
variant={presentation.variant}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action); }}
className={cn(
"h-6 px-2 text-[10px]",
presentation.destructiveReplace
&& "border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
aria-describedby={presentation.destructiveReplace ? folderReplaceWarningId : undefined}
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, applyToAll); }}
>
{t(`sftp.conflict.action.${action}`)}
{applyToAll && <> · {t("sftp.transferCenter.applyAll")}</>}
</Button>
))}
{(conflict.applyToAllCount ?? 0) > 1 && applyAllActions.map((action) => (
<Button
key={`all-${action}`}
variant="outline"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, true); }}
);
};
return (
<div className="mt-2 space-y-2">
{destructiveDirectoryReplace && (
<div
id={folderReplaceWarningId}
className="flex items-start gap-1.5 rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[10px] leading-4"
>
{t(`sftp.conflict.action.${action}`)} · {t("sftp.transferCenter.applyAll")}
</Button>
))}
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-destructive" />
<p>
{t("sftp.conflict.folderMergeHint")}{" "}
<span className="font-medium text-destructive">
{t("sftp.conflict.folderReplaceWarning")}
</span>
</p>
</div>
)}
{unresolvedFolderType && (
<div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4">
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />
<p>{t("sftp.conflict.folderUnknownDesc")}</p>
</div>
)}
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => renderConflictAction(action))}
{(conflict.applyToAllCount ?? 0) > 1
&& applyAllActions.map((action) => renderConflictAction(action, true))}
</div>
</div>
);
})()}
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff. Show more