Compare commits

...
Author SHA1 Message Date
bincxz d87a95725d fix(notes): keep mode options on one line 2026-08-25 10:20:24 +08:00
陈大猫 1bbb0e1f65 Merge pull request #3132 from binaricat/codex/remove-obsolete-mosh-terminfo
chore(mosh): remove obsolete macOS terminfo resources
2026-08-24 23:21:49 +08:00
陈大猫 f65a9ffc2d Merge pull request #3131 from binaricat/codex/refine-sftp-conflict-dialog
fix(sftp): clarify folder conflict choices
2026-08-24 23:21:30 +08:00
bincxz 05e9447998 fix(sftp): preserve conflict type for assistive tech 2026-08-24 23:11:13 +08:00
bincxz fbacff7983 chore(mosh): remove obsolete terminfo resources 2026-08-24 22:59:00 +08:00
bincxz 1b188c8aad fix(sftp): clarify folder conflict choices 2026-08-24 22:59:00 +08:00
陈大猫 eaacd01055 Merge pull request #3120 from Sammsai/main
feat(notes): add source mode, formulas, outline, and export tools
2026-08-24 22:51:55 +08:00
bincxz 2ab2321226 fix(vault): keep inline rename controls visible 2026-08-24 22:45:19 +08:00
bincxz 95e1a147d8 style(notes): align metadata and tree rows 2026-08-24 22:22:54 +08:00
bincxz a1f5181e58 fix(notes): avoid repeated code toolbar mutations 2026-08-24 21:55:11 +08:00
bincxz 971f702c32 refactor(notes): render formulas with katex 2026-08-24 21:43:16 +08:00
bincxz 5dbdf0965a fix(notes): protect raw source drafts 2026-08-24 21:06:36 +08:00
bincxz b24d6dd196 fix(notes): enforce explicit latex fence styles 2026-08-24 20:50:28 +08:00
bincxz b9ba7349c1 fix(notes): normalize modes and latex arguments 2026-08-24 20:42:01 +08:00
bincxz a40c0616d7 fix(notes): isolate selections and matrix scripts 2026-08-24 20:28:05 +08:00
bincxz 71e5c280fd fix(notes): preserve export and quote boundaries 2026-08-24 20:17:53 +08:00
bincxz b92084c477 fix(notes): require explicit latex formula blocks 2026-08-24 19:44:25 +08:00
bincxz ae669daf19 fix(notes): sanitize reserved export file stems 2026-08-24 19:22:28 +08:00
bincxz d049b85df4 fix(notes): address export and formula review findings 2026-08-24 19:07:56 +08:00
陈大猫 a151240017 Merge pull request #3128 from binaricat/codex/fix-terminal-prompt-dev-import
[Bug] Fix terminal loading in development
2026-08-24 19:06:59 +08:00
陈大猫 b09ae31830 Merge pull request #3129 from binaricat/codex/fix-2704-bastion-retry-window
fix(ssh): keep Copy Tab reuse through longer bastion limits
2026-08-24 19:03:15 +08:00
bincxz 0e74c310d2 fix(notes): cover formula and list edge cases 2026-08-24 18:57:09 +08:00
bincxz 359c2b7690 fix(notes): address latest review findings 2026-08-24 18:42:54 +08:00
bincxz 0fd7bed991 feat(notes): polish editor workflow and presentation 2026-08-24 18:32:56 +08:00
bincxz f23caa5026 test: cover late rate-limit timeout settlement 2026-08-24 18:04:29 +08:00
bincxz dd4cd0a587 fix: serialize abandoned shared shell opens 2026-08-24 18:02:13 +08:00
bincxz 54e32dbcae fix: preserve source transport on retry timeout 2026-08-24 17:57:55 +08:00
bincxz 8fc811d3e0 fix: bound and cancel Copy Tab retries safely 2026-08-24 17:51:25 +08:00
bincxz 1a12dfa173 fix(ssh): keep Copy Tab reuse through longer bastion limits 2026-08-24 17:40:16 +08:00
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
Sammsai ada3d13ca3 修复笔记源码模式光标自动跳到笔记末尾 2026-08-24 00:12:25 +08: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
Sammsai c138e1fc74 修复笔记源码模式光标自动跳到笔记末尾 2026-08-23 19:31:37 +08:00
Sammsai 600544fd7f 修复笔记源码模式光标自动跳到笔记末尾 2026-08-23 14:22:18 +08:00
Sammsai 7e379ea5f5 Update 2026-08-23 13:46:55 +08:00
Sammsai ac3eb88956 清除冗余代码 2026-08-23 13:44:55 +08:00
Sammsai 140d78ae9e 多语言支持 2026-08-23 13:02:05 +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
Sam 00f91c7f07 Add files via upload 2026-08-22 21:26:51 +08:00
Sam f5c5bcd17c Delete .github/workflows/build-windows-x64.yml 2026-08-22 21:24:39 +08:00
Sammsai dc6d1d9b60 笔记模块工具栏中增加了代码字号配置功能 2026-08-22 20:37:32 +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
Sammsai 4709ff2939 1 2026-08-22 19:49:13 +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
Sammsai e6ed93a4ac 1 2026-08-22 19:32:34 +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
Sammsai 29c90f1974 1 2026-08-22 15:30:45 +08:00
bincxz c4354d9451 fix: restore magnified pane from close control 2026-08-22 15:28:30 +08:00
Sammsai 0bf7e187e6 1 2026-08-22 15:17:49 +08:00
bincxz 08b9027ef1 fix: separate focus and pane magnification actions 2026-08-22 14:49:29 +08:00
Sammsai 76fd52e85a 1 2026-08-22 14:34:45 +08:00
Sammsai 9dc4336c4a 1 2026-08-22 13:36:15 +08:00
Sammsai bb71d28846 1 2026-08-22 13:34:43 +08:00
Sammsai 61b9cf2584 1 2026-08-22 13:22:57 +08:00
Sammsai acfc581719 1 2026-08-22 12:28:01 +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
Sammsai ff25cb1a71 1 2026-08-22 09:57:24 +08:00
Sammsai 497afbbe82 11 2026-08-22 07:05:38 +08:00
Sammsai 9b0c07e614 1 2026-08-21 23:35:32 +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
Sammsai 53dd8af4c0 12 2026-08-21 22:12:52 +08:00
Sammsai 2c4657016a 11 2026-08-21 21:05:41 +08:00
Sammsai 2568109538 1 2026-08-21 20:18:21 +08:00
Sammsai 49fc6e3fba 1 2026-08-21 19:47:17 +08:00
Sammsai c32646b570 1 2026-08-21 19:19:35 +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
Sammsai f034754455 笔记模块优化 2026-08-21 17:23:12 +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
陈大猫 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
Sammsai e6b5b76efb 笔记模块优化 2026-08-21 12:02:31 +08:00
Sammsai 0184d3c34f 笔记模块优化 2026-08-21 10:11:42 +08:00
Sammsai 32afe5bf4b 修复构建报错 2026-08-21 09:23:37 +08:00
Sammsai 1f337d28ed 笔记模块优化 2026-08-21 08:58:43 +08:00
Sammsai 3632b42e8e 修复界面无显示 2026-08-21 08:18:01 +08:00
Sammsai ea2c2ad301 笔记模块功能优化 2026-08-21 07:19:24 +08:00
Sammsai 988783c286 笔记模块功能增强与体验修复 2026-08-20 23:11:41 +08:00
Sammsai 33de6ee388 Merge branch 'main' of https://github.com/Sammsai/Netcatty 2026-08-20 22:15:30 +08:00
Sammsai d98cd37be6 笔记模块问题修复与优化 2026-08-20 22:15:24 +08:00
Sam ca344ad0a8 Delete .github/workflows/build.yml 2026-08-20 21:43:59 +08:00
Sammsai e7dcd22a7a fix(notes): fix syntax error in InlineMarkdownEditor exports 2026-08-20 21:42:41 +08:00
Sammsai d752897aa1 笔记模块升级 2026-08-20 21:29:48 +08:00
Sammsai be6909c5ea ci 2026-08-20 20:45:40 +08:00
Sammsai f3e67fc0aa 笔记模块重构与升级 2026-08-20 20:41:12 +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
bincxz ad1c5c9a3a fix(sftp): fail closed on unknown folder targets 2026-08-20 16:41:50 +08:00
bincxz 7a43157ae1 fix: index disconnect notice setting 2026-08-20 16:26:29 +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 91c611c9f3 fix: keep reconnect notice state current 2026-08-20 16:15:00 +08:00
bincxz 14f4b31097 fix: keep auto reconnect non-blocking 2026-08-20 16:09:15 +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 a8e5b4992c fix(sftp): make folder conflict merge the safe default 2026-08-20 15:50:16 +08:00
179 changed files with 12196 additions and 1184 deletions

No files matched your search

+6
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.
@@ -243,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
+1
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
+2 -1
View File
@@ -1,5 +1,6 @@
{
"recommendations": [
"dbaeumer.vscode-eslint"
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
}
+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) {
@@ -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\)/);
});
+43 -9
View File
@@ -52,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 {
@@ -89,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;
@@ -596,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.
@@ -923,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;
@@ -1065,8 +1068,10 @@ export function AppSideEffects() {
showSftpTab: showSftpTabRef.current,
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
},
sftpPaneMagnificationRef,
splitSessionWithCurrentShell,
systemInfoRef,
terminalPaneMagnificationRef,
toEditorTabId,
toggleBroadcast,
toggleScriptsSidePanelRef,
@@ -1091,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(),
@@ -1103,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;
@@ -1116,7 +1138,7 @@ 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.
@@ -1148,11 +1170,21 @@ export function AppSideEffects() {
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) => {
@@ -1853,6 +1885,8 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
terminalPaneMagnificationRef,
sftpPaneMagnificationRef,
// Chrome glue
handleEndSessionDrag,
handleOpenQuickSwitcher,
+25 -1
View File
@@ -36,6 +36,7 @@ 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';
@@ -276,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,
@@ -302,6 +303,24 @@ 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.
@@ -644,6 +663,7 @@ function AppViewInner({ domains }: AppViewProps) {
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
paneMagnificationRef={sftpPaneMagnificationRef}
/>
<TerminalLayerMount
@@ -738,6 +758,7 @@ function AppViewInner({ domains }: AppViewProps) {
showHostTreeSidebar={showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
paneMagnificationRef={terminalPaneMagnificationRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
@@ -889,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,
+11
View File
@@ -358,6 +358,12 @@ export const enCoreMessages: Messages = {
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
'settings.vault.autoImportSystemKnownHostsDesc': 'When Vault loads, silently import entries from the OpenSSH known_hosts file. Turn this off to keep Netcatty known hosts separate; Scan System still imports on demand.',
'settings.vault.notesFont': 'Code font',
'settings.vault.notesFontDesc': 'Font family used for code blocks and inline code in notes',
'settings.vault.notesFontSize': 'Note font size',
'settings.vault.notesFontSizeDesc': 'Font size used for note body text',
'settings.vault.notesCodeFontSize': 'Code font size',
'settings.vault.notesCodeFontSizeDesc': 'Font size used for code blocks and inline code in notes',
// Update notifications
'update.available.title': 'Update Available',
@@ -643,6 +649,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',
+4
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',
+62
View File
@@ -34,6 +34,61 @@ export const enVaultMessages: Messages = {
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
'notes.empty.group': 'No notes in this notebook.',
'notes.search.noResults': 'No notes found.',
'notes.toolbar.modeLive': 'Live Preview',
'notes.toolbar.modeLiveTitle': 'Live Preview Mode (WYSIWYG)',
'notes.toolbar.modeSource': 'Source Mode',
'notes.toolbar.modeSourceTitle': 'Source Mode (Markdown code)',
'notes.toolbar.modePreview': 'Reading Mode',
'notes.toolbar.modePreviewTitle': 'Reading Mode (Read-only)',
'notes.toolbar.undo': 'Undo (Ctrl+Z)',
'notes.toolbar.redo': 'Redo (Ctrl+Y)',
'notes.toolbar.typography': 'Code font and typography settings',
'notes.toolbar.customCodeFont': 'Custom Code Font',
'notes.toolbar.fontsAvailable': '{count} fonts available',
'notes.toolbar.searchFont': 'Search fonts...',
'notes.toolbar.noFontsFound': 'No matching monospace fonts found',
'notes.toolbar.defaultFont': 'Default Monospace (Default)',
'notes.toolbar.bodyFontSize': 'Body Font Size',
'notes.toolbar.codeFontSize': 'Code Font Size',
'notes.toolbar.headingLevel': 'Heading Level',
'notes.toolbar.h1': 'Heading 1',
'notes.toolbar.h2': 'Heading 2',
'notes.toolbar.h3': 'Heading 3',
'notes.toolbar.h4': 'Heading 4',
'notes.toolbar.bold': 'Bold (Ctrl+B)',
'notes.toolbar.italic': 'Italic (Ctrl+I)',
'notes.toolbar.strikethrough': 'Strikethrough',
'notes.toolbar.underline': 'Underline',
'notes.toolbar.inlineCode': 'Inline Code',
'notes.toolbar.bulletList': 'Bullet List',
'notes.toolbar.orderedList': 'Numbered List',
'notes.toolbar.taskList': 'Task List',
'notes.toolbar.quote': 'Quote',
'notes.toolbar.codeBlock': 'Code Block',
'notes.toolbar.mathFormula': 'Math Formula (LaTeX / Math)',
'notes.toolbar.table': 'Insert Table',
'notes.toolbar.divider': 'Divider',
'notes.toolbar.link': 'Insert Link',
'notes.toolbar.image': 'Insert Image',
'notes.action.pin': 'Pin Note',
'notes.action.unpin': 'Unpin Note',
'notes.tag.remove': 'Remove tag',
'notes.tag.placeholder': 'Tag name...',
'notes.tag.add': 'Add tag',
'notes.outline.title': 'Table of Contents',
'notes.outline.titleWithCount': 'Table of Contents ({count})',
'notes.outline.empty': 'No outline headings',
'notes.outline.emptyHint': 'Use # and ## in Markdown to create headings',
'notes.export.share': 'Export & Share',
'notes.export.currentNote': 'Current Note',
'notes.export.exportMarkdown': 'Export as Markdown (.md)',
'notes.export.exportHtml': 'Export as HTML (.html)',
'notes.export.copyMarkdown': 'Copy Markdown Source',
'notes.export.allNotes': 'All Notes',
'notes.export.exportAllZip': 'Export All as ZIP Archive',
'notes.export.toast.markdownSuccess': 'Markdown file exported',
'notes.export.toast.htmlSuccess': 'HTML file exported',
'notes.export.toast.zipSuccess': 'All notes exported as ZIP backup',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -396,6 +451,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': 'Choose how to continue.',
'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 destination-only content.',
'sftp.conflict.folderReplaceWarning': 'Replace: deletes destination-only content and cannot be undone.',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
'sftp.conflict.existingFile': 'Existing file',
'sftp.conflict.newFile': 'New file',
+12 -1
View File
@@ -279,6 +279,12 @@ export const esCoreMessages: Messages = {
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
'settings.vault.autoImportSystemKnownHostsDesc': 'Cuando se carga el Vault, importa en silencio las entradas del archivo known_hosts de OpenSSH. Desactívalo para mantener los hosts conocidos de Netcatty separados; Escanear sistema sigue importando a pedido.',
'settings.vault.notesFont': 'Fuente de código',
'settings.vault.notesFontDesc': 'Familia tipográfica para bloques de código y código en línea en notas',
'settings.vault.notesFontSize': 'Tamaño de fuente de notas',
'settings.vault.notesFontSizeDesc': 'Tamaño de fuente para el cuerpo de texto de las notas',
'settings.vault.notesCodeFontSize': 'Tamaño de fuente de código',
'settings.vault.notesCodeFontSizeDesc': 'Tamaño de fuente para bloques de código y código en línea en notas',
// Update notifications
'update.available.title': 'Actualización disponible',
@@ -564,6 +570,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.autoCloseOnExit': 'Cerrar la terminal automáticamente al salir',
'settings.terminal.behavior.autoCloseOnExit.desc':
'Permite que las pestañas y ventanas de terminal se cierren automáticamente después de que la sesión termina. Desactívalo para mantenerlas abiertas después de cada salida.',
'settings.terminal.behavior.disconnectedNotice': 'Cuando se desconecta una conexión',
'settings.terminal.behavior.disconnectedNotice.desc':
'Muestra un aviso compacto en terminales ya conectadas o usa el diálogo completo. Los fallos de conexión iniciales siguen usando el diálogo.',
'settings.terminal.behavior.disconnectedNotice.terminal': 'Aviso en terminal',
'settings.terminal.behavior.disconnectedNotice.dialog': 'Diálogo',
'settings.terminal.behavior.rightClick.menu': 'Mostrar menú',
'settings.terminal.behavior.rightClick.paste': 'Pegar',
'settings.terminal.behavior.rightClick.selectWord': 'Seleccionar palabra',
@@ -1099,4 +1110,4 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
};
};
+5 -1
View File
@@ -62,6 +62,9 @@ export const esTerminalMessages: Messages = {
'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',
@@ -183,6 +186,7 @@ export const esTerminalMessages: Messages = {
'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',
@@ -840,4 +844,4 @@ export const esTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
};
};
+63 -1
View File
@@ -34,6 +34,61 @@ export const esVaultMessages: Messages = {
'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.',
'notes.toolbar.modeLive': 'Vista en vivo',
'notes.toolbar.modeLiveTitle': 'Modo vista en vivo (WYSIWYG)',
'notes.toolbar.modeSource': 'Modo fuente',
'notes.toolbar.modeSourceTitle': 'Modo fuente (código Markdown)',
'notes.toolbar.modePreview': 'Modo lectura',
'notes.toolbar.modePreviewTitle': 'Modo lectura (solo lectura)',
'notes.toolbar.undo': 'Deshacer (Ctrl+Z)',
'notes.toolbar.redo': 'Rehacer (Ctrl+Y)',
'notes.toolbar.typography': 'Fuente de código y ajustes de tipografía',
'notes.toolbar.customCodeFont': 'Fuente de código personalizada',
'notes.toolbar.fontsAvailable': '{count} fuentes disponibles',
'notes.toolbar.searchFont': 'Buscar fuentes...',
'notes.toolbar.noFontsFound': 'No se encontraron fuentes monoespaciadas coincidentes',
'notes.toolbar.defaultFont': 'Monoespaciado predeterminado (Default)',
'notes.toolbar.bodyFontSize': 'Tamaño de texto',
'notes.toolbar.codeFontSize': 'Tamaño de código',
'notes.toolbar.headingLevel': 'Nivel de encabezado',
'notes.toolbar.h1': 'Encabezado 1',
'notes.toolbar.h2': 'Encabezado 2',
'notes.toolbar.h3': 'Encabezado 3',
'notes.toolbar.h4': 'Encabezado 4',
'notes.toolbar.bold': 'Negrita (Ctrl+B)',
'notes.toolbar.italic': 'Cursiva (Ctrl+I)',
'notes.toolbar.strikethrough': 'Tachado',
'notes.toolbar.underline': 'Subrayado',
'notes.toolbar.inlineCode': 'Código en línea',
'notes.toolbar.bulletList': 'Lista con viñetas',
'notes.toolbar.orderedList': 'Lista numerada',
'notes.toolbar.taskList': 'Lista de tareas',
'notes.toolbar.quote': 'Cita',
'notes.toolbar.codeBlock': 'Bloque de código',
'notes.toolbar.mathFormula': 'Fórmula matemática (LaTeX / Math)',
'notes.toolbar.table': 'Insertar tabla',
'notes.toolbar.divider': 'Línea divisoria',
'notes.toolbar.link': 'Insertar enlace',
'notes.toolbar.image': 'Insertar imagen',
'notes.action.pin': 'Fijar nota',
'notes.action.unpin': 'Desfijar nota',
'notes.tag.remove': 'Eliminar etiqueta',
'notes.tag.placeholder': 'Nombre de etiqueta...',
'notes.tag.add': 'Añadir etiqueta',
'notes.outline.title': 'Índice de contenido',
'notes.outline.titleWithCount': 'Índice de contenido ({count})',
'notes.outline.empty': 'Sin encabezados de esquema',
'notes.outline.emptyHint': 'Use # y ## en Markdown para crear encabezados',
'notes.export.share': 'Exportar y compartir',
'notes.export.currentNote': 'Nota actual',
'notes.export.exportMarkdown': 'Exportar como Markdown (.md)',
'notes.export.exportHtml': 'Exportar como HTML (.html)',
'notes.export.copyMarkdown': 'Copiar código Markdown',
'notes.export.allNotes': 'Todas las notas',
'notes.export.exportAllZip': 'Exportar todo como archivo ZIP',
'notes.export.toast.markdownSuccess': 'Archivo Markdown exportado',
'notes.export.toast.htmlSuccess': 'Archivo HTML exportado',
'notes.export.toast.zipSuccess': 'Todas las notas exportadas como copia de seguridad ZIP',
// Vault import
'vault.import.title': 'Agrega datos a tu Vault',
@@ -396,6 +451,13 @@ export const esVaultMessages: Messages = {
'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': 'Elige cómo continuar.',
'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 el contenido que solo existe en el destino.',
'sftp.conflict.folderReplaceWarning': 'Reemplazar: elimina el contenido exclusivo del destino y no se puede deshacer.',
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
'sftp.conflict.existingFile': 'Archivo existente',
'sftp.conflict.newFile': 'Archivo nuevo',
@@ -985,4 +1047,4 @@ export const esVaultMessages: Messages = {
'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.',
};
};
+11
View File
@@ -342,6 +342,12 @@ export const ruCoreMessages: Messages = {
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
'settings.vault.autoImportSystemKnownHostsDesc': 'При открытии хранилища тихо импортировать записи из OpenSSH known_hosts. Отключите, чтобы держать известные хосты Netcatty отдельно; кнопка «Сканировать систему» по-прежнему импортирует вручную.',
'settings.vault.notesFont': 'Шрифт кода',
'settings.vault.notesFontDesc': 'Шрифт для блоков кода и встроенного кода в заметках',
'settings.vault.notesFontSize': 'Размер шрифта заметок',
'settings.vault.notesFontSizeDesc': 'Размер шрифта для основного текста заметок',
'settings.vault.notesCodeFontSize': 'Размер шрифта кода',
'settings.vault.notesCodeFontSizeDesc': 'Размер шрифта для блоков кода и встроенного кода в заметках',
// Update notifications
'update.available.title': 'Доступно обновление',
@@ -600,6 +606,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': 'Выбрать слово',
+6
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': 'Показать журналы',
+62
View File
@@ -34,6 +34,61 @@ export const ruVaultMessages: Messages = {
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
'notes.empty.group': 'В этом блокноте пока нет заметок.',
'notes.search.noResults': 'Заметки не найдены.',
'notes.toolbar.modeLive': 'Живой просмотр',
'notes.toolbar.modeLiveTitle': 'Режим живого просмотра (WYSIWYG)',
'notes.toolbar.modeSource': 'Исходный код',
'notes.toolbar.modeSourceTitle': 'Режим исходного кода (Markdown)',
'notes.toolbar.modePreview': 'Чтение',
'notes.toolbar.modePreviewTitle': 'Режим чтения (только для чтения)',
'notes.toolbar.undo': 'Отменить (Ctrl+Z)',
'notes.toolbar.redo': 'Повторить (Ctrl+Y)',
'notes.toolbar.typography': 'Шрифт кода и параметры оформления',
'notes.toolbar.customCodeFont': 'Пользовательский шрифт кода',
'notes.toolbar.fontsAvailable': '{count} доступных шрифтов',
'notes.toolbar.searchFont': 'Поиск шрифтов...',
'notes.toolbar.noFontsFound': 'Совпадающие моноширинные шрифты не найдены',
'notes.toolbar.defaultFont': 'Моноширинный по умолчанию (Default)',
'notes.toolbar.bodyFontSize': 'Размер текста',
'notes.toolbar.codeFontSize': 'Размер кода',
'notes.toolbar.headingLevel': 'Уровень заголовка',
'notes.toolbar.h1': 'Заголовок 1',
'notes.toolbar.h2': 'Заголовок 2',
'notes.toolbar.h3': 'Заголовок 3',
'notes.toolbar.h4': 'Заголовок 4',
'notes.toolbar.bold': 'Жирный (Ctrl+B)',
'notes.toolbar.italic': 'Курсив (Ctrl+I)',
'notes.toolbar.strikethrough': 'Зачёркнутый',
'notes.toolbar.underline': 'Подчёркнутый',
'notes.toolbar.inlineCode': 'Встроенный код',
'notes.toolbar.bulletList': 'Маркированный список',
'notes.toolbar.orderedList': 'Нумерованный список',
'notes.toolbar.taskList': 'Список задач',
'notes.toolbar.quote': 'Цитата',
'notes.toolbar.codeBlock': 'Блок кода',
'notes.toolbar.mathFormula': 'Математическая формула (LaTeX / Math)',
'notes.toolbar.table': 'Вставить таблицу',
'notes.toolbar.divider': 'Разделитель',
'notes.toolbar.link': 'Вставить ссылку',
'notes.toolbar.image': 'Вставить изображение',
'notes.action.pin': 'Закрепить заметку',
'notes.action.unpin': 'Открепить заметку',
'notes.tag.remove': 'Удалить тег',
'notes.tag.placeholder': 'Имя тега...',
'notes.tag.add': 'Добавить тег',
'notes.outline.title': 'Оглавление',
'notes.outline.titleWithCount': 'Оглавление ({count})',
'notes.outline.empty': 'Нет заголовков',
'notes.outline.emptyHint': 'Используйте # и ## в Markdown для создания заголовков',
'notes.export.share': 'Экспорт и общий доступ',
'notes.export.currentNote': 'Текущая заметка',
'notes.export.exportMarkdown': 'Экспорт в Markdown (.md)',
'notes.export.exportHtml': 'Экспорт в HTML (.html)',
'notes.export.copyMarkdown': 'Копировать исходный Markdown',
'notes.export.allNotes': 'Все заметки',
'notes.export.exportAllZip': 'Экспортировать все в ZIP-архив',
'notes.export.toast.markdownSuccess': 'Markdown-файл экспортирован',
'notes.export.toast.htmlSuccess': 'HTML-файл экспортирован',
'notes.export.toast.zipSuccess': 'Все заметки экспортированы в архив ZIP',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
@@ -432,6 +487,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': 'Новый файл',
@@ -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}`);
}
}
});
@@ -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}`);
}
}
});
+6
View File
@@ -355,6 +355,12 @@ export const zhCNCoreMessages: Messages = {
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
'settings.vault.autoImportSystemKnownHostsDesc': '打开主机库时静默导入 OpenSSH known_hosts 中的记录。关闭后,Netcatty 已知主机与系统信任库保持独立;仍可通过「扫描系统」手动导入。',
'settings.vault.notesFont': '代码字体',
'settings.vault.notesFontDesc': '自定义笔记中代码块与行内代码使用的字体',
'settings.vault.notesFontSize': '笔记字号',
'settings.vault.notesFontSizeDesc': '自定义笔记正文字体大小',
'settings.vault.notesCodeFontSize': '代码字号',
'settings.vault.notesCodeFontSizeDesc': '自定义笔记中代码块与行内代码的字体大小',
// Update notifications
'update.available.title': '发现新版本',
@@ -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',
@@ -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': '选择单词',
+65
View File
@@ -34,6 +34,61 @@ export const zhCNVaultMessages: Messages = {
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
'notes.empty.group': '这个笔记本里还没有笔记。',
'notes.search.noResults': '没有找到笔记。',
'notes.toolbar.modeLive': '实时预览',
'notes.toolbar.modeLiveTitle': '实时预览模式 (WYSIWYG)',
'notes.toolbar.modeSource': '源码模式',
'notes.toolbar.modeSourceTitle': '源码模式 (Markdown 代码)',
'notes.toolbar.modePreview': '阅读模式',
'notes.toolbar.modePreviewTitle': '阅读模式 (只读排版)',
'notes.toolbar.undo': '撤销 (Ctrl+Z)',
'notes.toolbar.redo': '重做 (Ctrl+Y)',
'notes.toolbar.typography': '代码字体与排版设置',
'notes.toolbar.customCodeFont': '自定义代码字体',
'notes.toolbar.fontsAvailable': '共 {count} 款可用字体',
'notes.toolbar.searchFont': '搜索字体...',
'notes.toolbar.noFontsFound': '未找到匹配的等宽字体',
'notes.toolbar.defaultFont': '默认等宽字体 (Default)',
'notes.toolbar.bodyFontSize': '正文字号',
'notes.toolbar.codeFontSize': '代码字号',
'notes.toolbar.headingLevel': '标题层级',
'notes.toolbar.h1': '一级标题',
'notes.toolbar.h2': '二级标题',
'notes.toolbar.h3': '三级标题',
'notes.toolbar.h4': '四级标题',
'notes.toolbar.bold': '加粗 (Ctrl+B)',
'notes.toolbar.italic': '斜体 (Ctrl+I)',
'notes.toolbar.strikethrough': '删除线',
'notes.toolbar.underline': '下划线',
'notes.toolbar.inlineCode': '行内代码',
'notes.toolbar.bulletList': '无序列表',
'notes.toolbar.orderedList': '有序列表',
'notes.toolbar.taskList': '待办任务清单',
'notes.toolbar.quote': '引用块',
'notes.toolbar.codeBlock': '代码块',
'notes.toolbar.mathFormula': '数学公式块 (LaTeX / Math)',
'notes.toolbar.table': '插入表格',
'notes.toolbar.divider': '分割线',
'notes.toolbar.link': '插入超链接',
'notes.toolbar.image': '插入图片',
'notes.action.pin': '置顶笔记',
'notes.action.unpin': '取消置顶',
'notes.tag.remove': '移除标签',
'notes.tag.placeholder': '标签名...',
'notes.tag.add': '添加标签',
'notes.outline.title': '大纲目录',
'notes.outline.titleWithCount': '大纲目录 ({count})',
'notes.outline.empty': '暂无标题大纲',
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 编写各级标题',
'notes.export.share': '导出与分享',
'notes.export.currentNote': '当前笔记',
'notes.export.exportMarkdown': '导出为 Markdown (.md)',
'notes.export.exportHtml': '导出为 HTML (.html)',
'notes.export.copyMarkdown': '复制 Markdown 源码',
'notes.export.allNotes': '全部笔记',
'notes.export.exportAllZip': '导出全部为 ZIP 压缩包',
'notes.export.toast.markdownSuccess': '已导出 Markdown 文件',
'notes.export.toast.htmlSuccess': '已导出 HTML 文件',
'notes.export.toast.zipSuccess': '已导出全部笔记备份 ZIP',
// Select Host panel
'selectHost.title': '选择主机',
@@ -409,6 +464,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 +966,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': '新文件',
+22 -22
View File
@@ -90,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 動態模型目錄暫時無法使用,目前將使用內建模型清單。',
@@ -128,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',
@@ -154,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',
@@ -166,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',
@@ -215,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': '預設(海外)',
@@ -271,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',
@@ -283,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':
@@ -327,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',
@@ -386,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': '例如:檢查磁碟空間',
@@ -408,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 回合並取消進行中的工具',
@@ -427,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': '確認',
@@ -484,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': '檔案',
@@ -506,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': '網路搜尋',
@@ -517,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': '最大結果數',
@@ -526,7 +526,7 @@ 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': '自動 - 自由執行',
@@ -546,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': '指令模式(可選)',
@@ -581,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': '清除篩選',
@@ -592,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': '快捷控制',
@@ -654,5 +654,5 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.overwrite': '覆蓋',
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置為預設',
'settings.shortcuts.resetToDefault': '重設為預設值',
};
+186 -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,15 +349,21 @@ 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 已知主機與系統信任庫保持獨立;仍可透過「掃描系統」手動匯入。',
'settings.vault.notesFont': '程式碼字型',
'settings.vault.notesFontDesc': '自訂筆記中程式碼區塊與行內程式碼使用的字型',
'settings.vault.notesFontSize': '筆記字級',
'settings.vault.notesFontSizeDesc': '自訂筆記內文字型大小',
'settings.vault.notesCodeFontSize': '程式碼字級',
'settings.vault.notesCodeFontSizeDesc': '自訂筆記中程式碼區塊與行內程式碼的文字大小',
// Update notifications
'update.available.title': '發現新版本',
@@ -288,11 +376,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 +419,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 +494,7 @@ export const zhTWCoreMessages: Messages = {
}
\`\`\`
隱藏頂部標籤列裡的主機列表開關:
隱藏頂部分頁列裡的主機列表開關:
\`\`\`css
[data-section="top-tabs-host-tree-toggle"] {
@@ -435,7 +523,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 +531,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 +548,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 +592,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 +615,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 +647,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 +707,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 +720,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 +733,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 +756,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 +813,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 +843,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 +861,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 +886,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': '收藏此路徑',
@@ -823,7 +911,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.upload': '上傳',
'sftp.uploadFiles': '上傳檔案',
'sftp.uploadFolder': '上傳資料夾',
'sftp.dragDropToUpload': '拖拽檔案到這裡上傳',
'sftp.dragDropToUpload': '拖曳檔案到這裡上傳',
'sftp.retry': '重試',
'sftp.context.open': '開啟',
'sftp.context.navigateTo': '跳轉到這裡',
@@ -861,7 +949,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': '雙擊編輯路徑',
@@ -899,7 +987,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': '排隊中',
@@ -930,7 +1018,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': '定位路徑到終端',
@@ -946,7 +1034,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': '輸入新名稱',
@@ -980,7 +1068,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': '八進位制',
@@ -989,7 +1077,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',
+106 -101
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
@@ -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,9 +337,9 @@ 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': '全部工作階段',
@@ -377,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': '新增自訂規則',
@@ -412,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 伺服器)。',
@@ -447,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': '內嵌圖片',
@@ -478,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': '為聚焦窗格顯示邊框',
@@ -509,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': '垂直分割',
@@ -557,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': '切換側邊欄',
@@ -568,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': '轉到選取的目錄',
@@ -582,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} 設定環境變數。',
@@ -608,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 *',
@@ -683,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': '儲存金鑰',
@@ -700,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 金鑰',
@@ -712,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': '私鑰 *',
@@ -730,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',
@@ -748,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 歷史',
@@ -766,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': '對所有主機生效',
@@ -814,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',
@@ -834,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': '此快捷鍵與「{name}」衝突',
'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}」前請填寫以下變數。',
@@ -860,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',
@@ -883,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': '設定名稱',
@@ -902,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': '儲存密碼',
+117 -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,71 @@ export const zhTWVaultMessages: Messages = {
'notes.preview.empty': '暫無內容可預覽。',
'notes.empty.title': '還沒有筆記',
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
'notes.empty.group': '這個筆記本里還沒有筆記。',
'notes.empty.group': '這個筆記本裡還沒有筆記。',
'notes.search.noResults': '沒有找到筆記。',
'notes.toolbar.modeLive': '即時預覽',
'notes.toolbar.modeLiveTitle': '即時預覽模式 (WYSIWYG)',
'notes.toolbar.modeSource': '原始碼模式',
'notes.toolbar.modeSourceTitle': '原始碼模式 (Markdown 程式碼)',
'notes.toolbar.modePreview': '閱讀模式',
'notes.toolbar.modePreviewTitle': '閱讀模式 (唯讀排版)',
'notes.toolbar.undo': '復原 (Ctrl+Z)',
'notes.toolbar.redo': '重做 (Ctrl+Y)',
'notes.toolbar.typography': '程式碼字型與排版設定',
'notes.toolbar.customCodeFont': '自訂程式碼字型',
'notes.toolbar.fontsAvailable': '共 {count} 款可用字型',
'notes.toolbar.searchFont': '搜尋字型...',
'notes.toolbar.noFontsFound': '未找到相符的等寬字型',
'notes.toolbar.defaultFont': '預設等寬字型 (Default)',
'notes.toolbar.bodyFontSize': '內文字級',
'notes.toolbar.codeFontSize': '程式碼字級',
'notes.toolbar.headingLevel': '標題層級',
'notes.toolbar.h1': '一級標題',
'notes.toolbar.h2': '二級標題',
'notes.toolbar.h3': '三級標題',
'notes.toolbar.h4': '四級標題',
'notes.toolbar.bold': '粗體 (Ctrl+B)',
'notes.toolbar.italic': '斜體 (Ctrl+I)',
'notes.toolbar.strikethrough': '刪除線',
'notes.toolbar.underline': '底線',
'notes.toolbar.inlineCode': '行內程式碼',
'notes.toolbar.bulletList': '無序列表',
'notes.toolbar.orderedList': '有序列表',
'notes.toolbar.taskList': '待辦任務清單',
'notes.toolbar.quote': '引用區塊',
'notes.toolbar.codeBlock': '程式碼區塊',
'notes.toolbar.mathFormula': '數學公式區塊 (LaTeX / Math)',
'notes.toolbar.table': '插入表格',
'notes.toolbar.divider': '分隔線',
'notes.toolbar.link': '插入超連結',
'notes.toolbar.image': '插入圖片',
'notes.action.pin': '置頂筆記',
'notes.action.unpin': '取消置頂',
'notes.tag.remove': '移除標籤',
'notes.tag.placeholder': '標籤名...',
'notes.tag.add': '新增標籤',
'notes.outline.title': '大綱目錄',
'notes.outline.titleWithCount': '大綱目錄 ({count})',
'notes.outline.empty': '暫無標題大綱',
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 編寫各級標題',
'notes.export.share': '匯出與分享',
'notes.export.currentNote': '目前筆記',
'notes.export.exportMarkdown': '匯出為 Markdown (.md)',
'notes.export.exportHtml': '匯出為 HTML (.html)',
'notes.export.copyMarkdown': '複製 Markdown 源碼',
'notes.export.allNotes': '全部筆記',
'notes.export.exportAllZip': '匯出全部為 ZIP 壓縮包',
'notes.export.toast.markdownSuccess': '已匯出 Markdown 檔案',
'notes.export.toast.htmlSuccess': '已匯出 HTML 檔案',
'notes.export.toast.zipSuccess': '已匯出全部筆記備份 ZIP',
// 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 +125,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 +167,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 +207,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 +270,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 +325,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 +357,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 +387,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 +398,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 +450,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 +481,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 +612,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 +657,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 +677,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 +692,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 +750,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 +818,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 +867,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 +895,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 +932,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 +942,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 +959,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': '新檔案',
+3 -1
View File
@@ -154,7 +154,8 @@ export const useAvailableFonts = (): TerminalFont[] => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getAvailableFonts
fontStore.getAvailableFonts,
fontStore.getAvailableFonts,
);
};
@@ -165,6 +166,7 @@ export const useInstalledFontFamilies = (): string[] | null => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getInstalledFontFamilies,
fontStore.getInstalledFontFamilies,
);
};
@@ -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/);
});
+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();
@@ -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();
});
+15 -24
View File
@@ -951,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]);
+19
View File
@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createStoredNumberSyncHandler } from "./useStoredNumber.ts";
test("stored number instances adopt same-window adapter changes", () => {
let current = 14;
let received = 14;
const handler = createStoredNumberSyncHandler({
storageKey: "note-size",
readValue: () => current,
onValue: (value) => { received = value; },
});
current = 18;
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "other" } }));
assert.equal(received, 14);
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "note-size" } }));
assert.equal(received, 18);
});
+58 -9
View File
@@ -1,5 +1,35 @@
import { useCallback, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { useCallback, useEffect, useState } from "react";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../infrastructure/persistence/localStorageAdapter";
export const readStoredNumberValue = (
storageKey: string,
fallback: number,
clamp?: { min: number; max: number },
): number => {
if (typeof globalThis.localStorage === "undefined") return fallback;
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
};
export const createStoredNumberSyncHandler = ({
storageKey,
readValue,
onValue,
}: {
storageKey: string;
readValue: () => number;
onValue: (value: number) => void;
}) => (event: Event) => {
const key = event.type === "storage"
? (event as StorageEvent).key
: (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === storageKey) onValue(readValue());
};
/**
* Hook for reading a number from localStorage with lazy persistence.
@@ -13,13 +43,32 @@ export const useStoredNumber = (
fallback: number,
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
if (typeof globalThis.localStorage === "undefined") return fallback;
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
});
const clampMin = clamp?.min;
const clampMax = clamp?.max;
const readValue = useCallback(
() => readStoredNumberValue(
storageKey,
fallback,
clampMin != null && clampMax != null ? { min: clampMin, max: clampMax } : undefined,
),
[clampMax, clampMin, fallback, storageKey],
);
const [value, setValue] = useState<number>(readValue);
useEffect(() => {
const target = globalThis as typeof globalThis & {
addEventListener?: (type: string, listener: EventListener) => void;
removeEventListener?: (type: string, listener: EventListener) => void;
};
if (typeof target.addEventListener !== "function") return;
const sync = createStoredNumberSyncHandler({ storageKey, readValue, onValue: setValue });
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
target.addEventListener("storage", sync);
return () => {
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
target.removeEventListener?.("storage", sync);
};
}, [readValue, storageKey]);
const persist = useCallback(
(v: number) => {
+13
View File
@@ -104,3 +104,16 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
"edit",
);
});
test("stored string helpers work with default validator when isAllowedValue is omitted", (t) => {
const env = installLocalStorage();
t.after(() => env.restore());
const storageKey = "netcatty:test-font";
assert.equal(readStoredStringValue(storageKey, "default-font"), "default-font");
assert.equal(readOptionalStoredStringValue(storageKey), null);
env.storage.setItem(storageKey, "Inter");
assert.equal(readStoredStringValue(storageKey, "default-font"), "Inter");
assert.equal(readOptionalStoredStringValue(storageKey), "Inter");
});
+19 -13
View File
@@ -8,23 +8,27 @@ type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) =
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
export const readStoredStringValue = <T extends string>(
const defaultIsAllowedString = <T extends string>(value: string | null): value is T => typeof value === "string";
export const readStoredStringValue = <T extends string = string>(
storageKey: string,
fallback: T,
isAllowedValue: (value: string | null) => value is T,
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
): T => {
if (!canUseLocalStorage()) return fallback;
const stored = localStorageAdapter.readString(storageKey);
return isAllowedValue(stored) ? stored : fallback;
const validator = isAllowedValue ?? defaultIsAllowedString;
return validator(stored) ? stored : fallback;
};
export const readOptionalStoredStringValue = <T extends string>(
export const readOptionalStoredStringValue = <T extends string = string>(
storageKey: string,
isAllowedValue: (value: string | null) => value is T,
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
): T | null => {
if (!canUseLocalStorage()) return null;
const stored = localStorageAdapter.readString(storageKey);
return isAllowedValue(stored) ? stored : null;
const validator = isAllowedValue ?? defaultIsAllowedString;
return validator(stored) ? stored : null;
};
export const resolveStoredStringUpdate = <T extends string>(
@@ -47,11 +51,12 @@ export const createStoredStringSyncHandlers = <T extends string>({
}: {
storageKey: string;
fallback: T;
isAllowedValue: (value: string | null) => value is T;
isAllowedValue?: (value: string | null) => value is T;
onValue: (value: T) => void;
}) => {
const validator = isAllowedValue ?? defaultIsAllowedString;
const syncFromStorage = () => {
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
onValue(readStoredStringValue(storageKey, fallback, validator));
};
return {
@@ -64,15 +69,16 @@ export const createStoredStringSyncHandlers = <T extends string>({
};
};
export const useStoredString = <T extends string>(
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
isAllowedValue: (value: string | null) => value is T,
isAllowedValue?: (value: string | null) => value is T,
) => {
const validator = isAllowedValue ?? defaultIsAllowedString;
const [value, setValue] = useState<T>(() => readStoredStringValue(
storageKey,
fallback,
isAllowedValue,
validator,
));
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
@@ -98,7 +104,7 @@ export const useStoredString = <T extends string>(
} = createStoredStringSyncHandlers({
storageKey,
fallback,
isAllowedValue,
isAllowedValue: validator,
onValue: setValue,
});
@@ -108,7 +114,7 @@ export const useStoredString = <T extends string>(
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
target.removeEventListener?.("storage", handleBrowserStorage);
};
}, [fallback, isAllowedValue, storageKey]);
}, [fallback, storageKey, validator]);
return [value, setAndPersist] as const;
};
+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' };
}
+44
View File
@@ -292,6 +292,30 @@ test("terminal side panel auto-open settings are syncable for auto-sync detectio
);
});
test("note appearance settings survive sync and trigger auto-sync", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
assert.equal(payload.settings?.noteFontSize, 16);
assert.equal(payload.settings?.noteCodeFontSize, 14);
for (const key of [
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
]) {
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
}
localStorage.clear();
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
@@ -1299,6 +1323,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,
+20 -1
View File
@@ -108,6 +108,9 @@ import {
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
} from '../infrastructure/config/storageKeys';
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
@@ -252,7 +255,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'autoCloseOnExit',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
@@ -275,6 +278,9 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
@@ -432,6 +438,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (lang) settings.uiLanguage = lang;
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
if (css != null) settings.customCSS = css;
const noteFontFamily = localStorageAdapter.readString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY);
if (noteFontFamily != null) settings.noteFontFamily = noteFontFamily;
const noteFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE);
if (noteFontSize != null && noteFontSize >= 10 && noteFontSize <= 32) settings.noteFontSize = noteFontSize;
const noteCodeFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE);
if (noteCodeFontSize != null && noteCodeFontSize >= 10 && noteCodeFontSize <= 32) settings.noteCodeFontSize = noteCodeFontSize;
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
@@ -655,6 +667,13 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
if (settings.noteFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, settings.noteFontFamily);
if (settings.noteFontSize != null && settings.noteFontSize >= 10 && settings.noteFontSize <= 32) {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE, settings.noteFontSize);
}
if (settings.noteCodeFontSize != null && settings.noteCodeFontSize >= 10 && settings.noteCodeFontSize <= 32) {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, settings.noteCodeFontSize);
}
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
@@ -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>
);
})()}
@@ -3,11 +3,27 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
buildPaneMagnificationPaletteAction,
buildPluginPaletteItems,
getQuickSwitcherRowStateClass,
shouldUseQuickSwitcherPointerNavigation,
} from './QuickSwitcher';
test('command palette follows the active pane magnification state', () => {
const t = (key: string) => key;
assert.equal(buildPaneMagnificationPaletteAction('unavailable', t), null);
assert.deepEqual(buildPaneMagnificationPaletteAction('focusable', t), {
type: 'action',
id: 'magnify-current-pane',
title: 'terminal.paneMagnification.magnify',
});
assert.deepEqual(buildPaneMagnificationPaletteAction('focused', t), {
type: 'action',
id: 'restore-magnified-pane',
title: 'terminal.paneMagnification.restore',
});
});
test('keeps the new workspace action outside the scrollable results', () => {
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
const actionIndex = source.indexOf('{/* Jump To hint + New Workspace action */}');
+63
View File
@@ -2,6 +2,8 @@ import {
Folder,
FolderLock,
LayoutGrid,
Maximize2,
Minimize2,
Plus,
Search,
Terminal,
@@ -11,6 +13,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import type { PaneMagnificationState } from "../domain/paneMagnification";
import { matchesSearchQuery } from "../lib/searchMatcher";
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
import { usePluginContributions } from "../application/state/usePluginContributions";
@@ -34,6 +37,19 @@ type QuickSwitcherItem = QuickSwitcherItemBase & (
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
);
export function buildPaneMagnificationPaletteAction(
state: PaneMagnificationState,
t: (key: string) => string,
): QuickSwitcherItem | null {
if (state === 'focusable') {
return { type: 'action', id: 'magnify-current-pane', title: t('terminal.paneMagnification.magnify') };
}
if (state === 'focused') {
return { type: 'action', id: 'restore-magnified-pane', title: t('terminal.paneMagnification.restore') };
}
return null;
}
export function getQuickSwitcherRowStateClass(
isSelected: boolean,
isKeyboardNavigating: boolean,
@@ -146,6 +162,9 @@ interface QuickSwitcherProps {
keyBindings?: KeyBinding[];
showSftpTab: boolean;
terminalSettings?: Pick<TerminalSettings, "localShell" | "localShellArgs">;
paneMagnificationState?: PaneMagnificationState;
onMagnifyCurrentPane?: () => void;
onRestoreMagnifiedPane?: () => void;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -164,6 +183,9 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
keyBindings,
showSftpTab,
terminalSettings,
paneMagnificationState = 'unavailable',
onMagnifyCurrentPane,
onRestoreMagnifiedPane,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -287,6 +309,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
pluginContributions.snapshot.plugins,
trimmedQuery,
), [pluginContributions.snapshot.plugins, trimmedQuery]);
const paneMagnificationAction = useMemo(
() => buildPaneMagnificationPaletteAction(paneMagnificationState, t),
[paneMagnificationState, t],
);
// Memoize flat selectable items + visual rows (headers + items) for virtualization.
const { flatItems, visualRows, itemIndexToVisualIndex } = useMemo(() => {
@@ -343,6 +369,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
pushItem({ type: "action", id: "local-terminal" });
}
if (paneMagnificationAction && (
!trimmedQuery
|| matchesSearchQuery(trimmedQuery, paneMagnificationAction.title ?? '', 'pane', 'magnify', 'zoom')
)) {
pushHeader("header:pane-actions", t("settings.shortcuts.category.navigation"));
pushItem(paneMagnificationAction);
}
if (pluginPaletteItems.length > 0) {
pushHeader("header:plugins", t("settings.tab.plugins"));
pluginPaletteItems.forEach((item) => pushItem(item));
@@ -359,9 +393,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
filteredShells,
filteredWorkspaces,
pluginPaletteItems,
paneMagnificationAction,
results,
shouldShowLocalTerminalFallback,
t,
trimmedQuery,
]);
useEffect(() => {
@@ -421,6 +457,12 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
if (item.id === "local-terminal" && onCreateLocalTerminal) {
onCreateLocalTerminal();
onClose();
} else if (item.id === 'magnify-current-pane' && onMagnifyCurrentPane) {
onMagnifyCurrentPane();
onClose();
} else if (item.id === 'restore-magnified-pane' && onRestoreMagnifiedPane) {
onRestoreMagnifiedPane();
onClose();
}
break;
case "shell": {
@@ -679,6 +721,27 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
}
if (item.type === 'action' && (
item.id === 'magnify-current-pane' || item.id === 'restore-magnified-pane'
)) {
const restoring = item.id === 'restore-magnified-pane';
return (
<button
type="button"
className={`${rowClass} flex h-full w-full items-center gap-3 px-4 text-left`}
onClick={() => handleItemSelect(item)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
{restoring ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
<kbd className="shrink-0 text-[10px] text-muted-foreground">
{getHotkeyLabel('toggle-pane-zoom').replace(/ \+ /g, '+')}
</kbd>
</button>
);
}
if (item.type === "plugin-command" || item.type === "plugin-view") {
return (
<button
+20 -1
View File
@@ -25,6 +25,8 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
import { usePluginContributions } from "../application/state/usePluginContributions";
import { PluginContributionHost } from "./plugins/PluginContributionHost";
import { matchesKeyBinding } from "../domain/models";
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
@@ -314,6 +316,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const { available: pluginRuntimeAvailable } = usePluginContributions();
const closeTabKeyStr = useMemo(() => {
if (settings.hotkeyScheme === "disabled") return null;
const binding = settings.keyBindings.find((item) => item.action === "closeTab");
if (!binding) return null;
return settings.hotkeyScheme === "mac" ? binding.mac : binding.pc;
}, [settings.hotkeyScheme, settings.keyBindings]);
const nativeCommandWClosesSettings = isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true);
useEffect(() => {
notifyRendererReady();
@@ -338,10 +347,20 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
useEffect(() => {
const unsubscribe = onWindowCommandCloseRequested(() => {
if (!nativeCommandWClosesSettings) {
(document.activeElement ?? window).dispatchEvent(new KeyboardEvent("keydown", {
key: "w",
code: "KeyW",
metaKey: true,
bubbles: true,
cancelable: true,
}));
return;
}
void closeSettingsWindow();
});
return () => unsubscribe?.();
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
}, [closeSettingsWindow, nativeCommandWClosesSettings, onWindowCommandCloseRequested]);
useEffect(() => {
setMountedTabs((prev) => {
+21
View File
@@ -10,6 +10,27 @@ test("main SftpView keeps browse sessions across top-tab switches", () => {
assert.doesNotMatch(source, /interactive:\s*isActive/);
});
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
assert.deepEqual(resolveTwoPaneMagnificationStyle('left', true, false), {
left: '0%', top: '0%', width: '50%', height: '100%', zIndex: 10,
});
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, false), {
left: '50%', top: '0%', width: '50%', height: '100%', zIndex: 10,
});
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, true), {
left: '12px', top: '12px', width: 'calc(100% - 24px)', height: 'calc(100% - 24px)', zIndex: 50,
});
});
test("SFTP keyboard focus tracks the active side and blocks the covered sibling", () => {
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
assert.match(source, /inert=\{magnifiedSide === 'right' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("left"\)\}/);
assert.match(source, /inert=\{magnifiedSide === 'left' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("right"\)\}/);
});
test("SftpView re-renders when host-key verification setting changes", async () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
+102 -5
View File
@@ -14,13 +14,18 @@
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
*/
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
import {
getPaneMagnificationShortcutLabel,
resolveTwoPaneMagnificationStyle,
type PaneMagnificationController,
} from "../domain/paneMagnification";
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
@@ -73,6 +78,7 @@ interface SftpViewProps {
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
paneMagnificationRef?: React.MutableRefObject<PaneMagnificationController | null>;
}
const SftpViewInner: React.FC<SftpViewProps> = ({
@@ -96,8 +102,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
editorWordWrap,
setEditorWordWrap,
terminalSettings,
paneMagnificationRef,
}) => {
const { t } = useI18n();
const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme);
const isActive = useIsSftpActive();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
@@ -222,12 +230,71 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
// Subscribe to focused side for visual indicator
const focusedSide = useSftpFocusedSide();
const [magnifiedSide, setMagnifiedSide] = useState<SftpFocusedSide | null>(null);
const focusedSideRef = useRef(focusedSide);
const magnifiedSideRef = useRef(magnifiedSide);
focusedSideRef.current = focusedSide;
magnifiedSideRef.current = magnifiedSide;
const [isWideSplit, setIsWideSplit] = useState(true);
const [showMagnificationHint, setShowMagnificationHint] = useState(false);
const splitSurfaceRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const surface = splitSurfaceRef.current;
if (!surface) return undefined;
const update = () => setIsWideSplit(surface.clientWidth >= 1024);
update();
if (typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(update);
observer.observe(surface);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!magnifiedSide) {
setShowMagnificationHint(false);
return undefined;
}
setShowMagnificationHint(true);
const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800);
return () => window.clearTimeout(timerId);
}, [magnifiedSide]);
useEffect(() => {
if (!paneMagnificationRef) return undefined;
const controller: PaneMagnificationController = {
getState: () => {
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return 'unavailable';
return magnifiedSideRef.current ? 'focused' : 'focusable';
},
focus: () => {
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || magnifiedSideRef.current) return false;
setMagnifiedSide(focusedSideRef.current);
return true;
},
restore: () => {
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || !magnifiedSideRef.current) return false;
setMagnifiedSide(null);
return true;
},
toggle: () => {
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return false;
setMagnifiedSide((current) => current ? null : focusedSideRef.current);
return true;
},
};
paneMagnificationRef.current = controller;
return () => {
if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null;
};
}, [paneMagnificationRef]);
// Handle pane focus when clicking on a pane container
// Clear the opposite side's selection so file operations only affect the focused pane
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
const prevSide = sftpFocusStore.getFocusedSide();
sftpFocusStore.setFocusedSide(side);
setMagnifiedSide((current) => current ? side : null);
if (prevSide !== side) {
if (targetTabId) {
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
@@ -481,10 +548,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
)}
style={containerStyle}
>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
<div
ref={splitSurfaceRef}
className="relative flex-1 min-h-0 border-t border-border/70 overflow-hidden"
data-section="sftp-main-split"
data-magnified-side={magnifiedSide ?? undefined}
>
{magnifiedSide && (
<div
className="absolute inset-0 z-40 bg-background/55 backdrop-blur-[1px]"
aria-hidden="true"
data-section="pane-magnification-backdrop"
/>
)}
<div
className="relative border-r border-border/70 flex flex-col"
className="absolute min-w-0 border-r border-border/70 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
style={resolveTwoPaneMagnificationStyle('left', isWideSplit, magnifiedSide === 'left')}
data-sftp-pane-side="left"
inert={magnifiedSide === 'right' ? true : undefined}
onClick={() => handlePaneFocus("left")}
onFocusCapture={() => handlePaneFocus("left")}
>
{/* Focus indicator triangle */}
{focusedSide === "left" && (
@@ -544,8 +627,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
</div>
</div>
<div
className="relative flex flex-col"
className="absolute min-w-0 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
style={resolveTwoPaneMagnificationStyle('right', isWideSplit, magnifiedSide === 'right')}
data-sftp-pane-side="right"
inert={magnifiedSide === 'left' ? true : undefined}
onClick={() => handlePaneFocus("right")}
onFocusCapture={() => handlePaneFocus("right")}
>
{/* Focus indicator triangle */}
{focusedSide === "right" && (
@@ -604,6 +691,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
)}
</div>
</div>
{magnifiedSide && showMagnificationHint && (
<div
className="pointer-events-none absolute bottom-4 right-4 z-[60] rounded border border-border/70 bg-background/90 px-2 py-1 text-[10px] text-muted-foreground shadow-sm animate-in fade-in duration-150"
data-section="pane-magnification-hint"
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
</div>
)}
</div>
<SftpOverlays
@@ -669,6 +765,7 @@ export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): bool
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.paneMagnificationRef === next.paneMagnificationRef &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
// Only these terminal connection settings affect SFTP connection
+12
View File
@@ -1881,6 +1881,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
draggingPackagePathRef.current = pkg.path;
setDraggingPackagePath(pkg.path);
lastPreviewReorderRef.current = null;
const sourceNode = e.currentTarget as HTMLElement;
const handleNativeDragEnd = () => {
sourceNode.removeEventListener('dragend', handleNativeDragEnd);
resetSnippetDragState();
};
sourceNode.addEventListener('dragend', handleNativeDragEnd);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
@@ -1965,6 +1971,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
draggingSnippetIdRef.current = snippet.id;
setDraggingSnippetId(snippet.id);
lastPreviewReorderRef.current = null;
const sourceNode = e.currentTarget as HTMLElement;
const handleNativeDragEnd = () => {
sourceNode.removeEventListener('dragend', handleNativeDragEnd);
resetSnippetDragState();
};
sourceNode.addEventListener('dragend', handleNativeDragEnd);
}}
onClick={() => {
if (isMultiSelectMode) {
+73 -16
View File
@@ -249,6 +249,7 @@ import {
MAX_CONNECTION_LOG_DATA_CHARS,
shouldDelayAutoRunSnippetInput,
shouldHideConnectingDialogForConnectionReuse,
shouldShowTerminalDisconnectedNotice,
shouldShowTerminalConnectionDialog,
type TerminalProps,
} from "./terminal/terminalHelpers";
@@ -273,6 +274,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
inWorkspace,
isResizing,
isFocusMode,
isPaneMagnified,
isFocused,
isFocusedPane,
fontFamilyId,
@@ -311,6 +313,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateHost,
onAddKnownHost,
onExpandToFocus,
onTogglePaneMagnification,
onCommandExecuted,
onCommandSubmitted,
onSplitHorizontal,
@@ -539,6 +542,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectPreparationTokenRef = useRef<symbol | null>(null);
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoReconnectLoopActiveRef = useRef(false);
const [manualReconnectActive, setManualReconnectActive] = useState(false);
const autoReconnectAttemptRef = useRef(0);
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -880,6 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} | null>(null);
const [isConnectionAwaitingUserInput, setIsConnectionAwaitingUserInput] = useState(false);
const [isConnectionPastTcpDial, setIsConnectionPastTcpDial] = useState(false);
const [reconnectNoticeMessage, setReconnectNoticeMessage] = useState<string | null>(null);
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
const [isComposeBarOpen, setIsComposeBarOpen] = useStoredBoolean(
@@ -1348,7 +1353,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const resolvedChainHosts =
chainHosts;
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean; clearNotice?: boolean }) => {
if (autoReconnectTimerRef.current) {
clearTimeout(autoReconnectTimerRef.current);
autoReconnectTimerRef.current = null;
@@ -1357,12 +1362,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
autoReconnectLoopActiveRef.current = false;
autoReconnectAttemptRef.current = 0;
}
if (options?.clearNotice !== false) setReconnectNoticeMessage(null);
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
statusRef.current = next;
setStatus(next);
hasConnectedRef.current = next === "connected";
if (next !== "connecting") {
setManualReconnectActive(false);
terminalReconnectRegistry.setActive(sessionId, false);
}
if (next === "connected") {
hasEverConnectedRef.current = true;
clearAutoReconnect();
@@ -1405,6 +1415,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const attempt = autoReconnectAttemptRef.current;
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
setReconnectNoticeMessage(scheduledMessage);
setError(null);
setShowLogs(true);
@@ -3410,18 +3421,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const startReconnect = async (mode: "manual" | "auto" = "manual") => {
if (attachExistingSession) return;
if (
reconnectPreparationTokenRef.current !== null
|| reconnectWakeInFlightRef.current
) return;
setManualReconnectActive(mode === "manual");
if (mode === "manual") terminalReconnectRegistry.setActive(sessionId, true);
const reconnectAttemptMessage = mode === "auto"
? t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })
: t("terminal.progress.reconnecting");
setReconnectNoticeMessage(reconnectAttemptMessage);
const restoreDisconnectedAfterReconnectFailure = () => {
if (mode === "manual") setReconnectNoticeMessage(null);
updateStatus("disconnected");
};
if (!termRef.current && hibernatedRef.current) {
if (reconnectWakeInFlightRef.current) return;
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
if (!wakeForReconnect) {
updateStatus("disconnected");
restoreDisconnectedAfterReconnectFailure();
return;
}
reconnectWakeInFlightRef.current = true;
const wakeToken = Symbol();
reconnectWakeInvalidateModeRef.current = "dispose";
reconnectWakeTokenRef.current = wakeToken;
updateStatus("connecting");
if (mode === "auto") updateStatus("connecting");
void wakeForReconnect().then((woke) => {
if (reconnectWakeTokenRef.current !== wakeToken) {
reconnectWakeInFlightRef.current = false;
@@ -3436,7 +3461,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startReconnectRef.current?.(mode);
return;
}
updateStatus("disconnected");
restoreDisconnectedAfterReconnectFailure();
}).catch(() => {
if (reconnectWakeTokenRef.current !== wakeToken) {
reconnectWakeInFlightRef.current = false;
@@ -3447,11 +3472,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
reconnectWakeTokenRef.current = null;
reconnectWakeInFlightRef.current = false;
updateStatus("disconnected");
restoreDisconnectedAfterReconnectFailure();
});
return;
}
if (!termRef.current) return;
if (!termRef.current) {
restoreDisconnectedAfterReconnectFailure();
return;
}
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
@@ -3476,6 +3504,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
} else if (mode === "manual" && retryTokenStillCurrent()) {
restoreDisconnectedAfterReconnectFailure();
}
return;
}
@@ -3490,7 +3520,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (mode === "manual") {
clearAutoReconnect();
clearAutoReconnect({ clearNotice: false });
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
@@ -3521,6 +3551,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
if (mode === "manual" && retryTokenStillCurrent()) {
restoreDisconnectedAfterReconnectFailure();
}
throw error;
}
if (!retryTokenStillCurrent()) {
@@ -3530,6 +3563,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const term = termRef.current;
if (!term) {
finishReconnectPreparation();
if (mode === "manual" && retryTokenStillCurrent()) {
restoreDisconnectedAfterReconnectFailure();
}
return;
}
// closeSession wiped preload ready listeners; re-arm before startMosh so a
@@ -3555,9 +3591,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
updateStatus("connecting");
setError(null);
setProgressLogs((prev) => (
mode === "auto"
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
: ["Retrying secure channel..."]
[...prev, reconnectAttemptMessage]
));
setShowLogs(true);
@@ -3635,17 +3669,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
);
}, [attachExistingSession, sessionId]);
const isReconnectActive = autoReconnectLoopActiveRef.current || manualReconnectActive;
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
isLocalConnection,
isSerialConnection,
isDisconnectedDialogDismissed,
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
hasEverConnected: hasEverConnectedRef.current,
restoreState,
isReconnectActive,
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
reuseConnectionFromSessionId: connectionReuseAttemptSourceId,
host,
connectionReuseFellBack,
}),
});
const showDisconnectedTerminalNotice = shouldShowTerminalDisconnectedNotice({
status,
disconnectedNoticeMode: terminalSettings.disconnectedNoticeMode,
hasEverConnected: hasEverConnectedRef.current,
restoreState,
isReconnectActive,
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
});
const {
handleDragEnter,
@@ -3787,7 +3835,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
}, [sessionId, t]);
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => (
<TerminalToolbar
sessionId={sessionId}
workspaceId={workspaceId}
@@ -3812,9 +3860,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}) ? () => setOsc7SetupOpen(true) : undefined}
onUpdateHost={handleUpdateHostFromTerminal}
showClose={opts?.showClose}
// Workspace toolbar X closes/destroys this pane session. Detach to a
// standalone tab remains a separate control (SquareArrowOutUpRight).
onClose={() => onCloseSession?.(sessionId)}
onClose={() => {
if (opts?.restorePaneLayout) {
onTogglePaneMagnification?.();
return;
}
onCloseSession?.(sessionId);
}}
closeLabel={t(opts?.restorePaneLayout
? 'terminal.paneMagnification.restore'
: 'terminal.toolbar.closeSession')}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
showLogButton
@@ -3866,6 +3921,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenHistory,
onOpenTheme,
onToggleComposeBar,
onTogglePaneMagnification,
setIsComposeBarOpen,
handleUpdateHostFromTerminal,
sessionId,
@@ -3873,6 +3929,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
status,
terminalEncoding,
t,
recorder,
workspaceId,
]);
@@ -4216,7 +4273,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
@@ -4225,7 +4282,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+174 -1
View File
@@ -113,6 +113,12 @@ import {
sidePanelLayoutHasTool,
type SidePanelSplitDirection,
} from '../domain/sidePanelLayout';
import {
isPaneMagnificationSelectionValid,
resolvePaneMagnificationCandidate,
type PaneMagnificationController,
type PaneMagnificationTarget,
} from '../domain/paneMagnification';
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
import {
TERMINAL_SIDE_PANEL_MAX_WIDTH,
@@ -257,12 +263,20 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
showHostTreeSidebar = true,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
paneMagnificationRef,
// Session rename props
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
}) => {
const { t } = useI18n();
const [magnifiedPane, setMagnifiedPane] = useState<{
tabId: string;
target: PaneMagnificationTarget;
} | null>(null);
const magnifiedPaneRef = useRef(magnifiedPane);
magnifiedPaneRef.current = magnifiedPane;
const lastInteractedPaneRef = useRef<Map<string, PaneMagnificationTarget>>(new Map());
// Side panel state must be initialized before any callbacks reference its
// setters or refs in their dependency arrays.
const {
@@ -277,6 +291,21 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
closePane: closeSidePanelPaneForTab,
resizeSplit: resizeSidePanelSplitForTab,
} = useTerminalSidePanelLayoutState();
useEffect(() => {
setMagnifiedPane((current) => {
if (!current) return current;
const terminalPanes = sessions.map((session) => ({
tabId: session.workspaceId ?? session.id,
sessionId: session.id,
}));
const sidePanelPanes = Array.from(sidePanelLayouts.entries()).flatMap(([tabId, layout]) => (
collectSidePanelPanes(layout.root).map((pane) => ({ tabId, paneId: pane.id }))
));
return isPaneMagnificationSelectionValid(current, terminalPanes, sidePanelPanes)
? current
: null;
});
}, [sessions, sidePanelLayouts]);
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
@@ -368,6 +397,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Stable callback references for Terminal components
const handleCloseSession = useCallback((sessionId: string) => {
setMagnifiedPane((current) => (
current?.target.kind === 'terminal' && current.target.sessionId === sessionId
? null
: current
));
for (const [tabId, target] of lastInteractedPaneRef.current) {
if (target.kind === 'terminal' && target.sessionId === sessionId) {
lastInteractedPaneRef.current.delete(tabId);
}
}
clearTerminalSessionRuntimeState({
terminalRendererCwdBySessionRef,
terminalOsc7SignalBySessionRef,
@@ -1302,6 +1341,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleCloseSidePanel = useCallback(() => {
const activeTabId = activeTabIdRef.current;
if (!activeTabId) return;
setMagnifiedPane((current) => (
current?.tabId === activeTabId && current.target.kind === 'side-panel'
? null
: current
));
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
closeTerminalSidePanelTab(activeTabId);
@@ -1394,8 +1438,125 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleFocusSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const layout = sidePanelLayoutsRef.current.get(tabId);
const pane = layout
? collectSidePanelPanes(layout.root).find((candidate) => candidate.id === paneId)
: undefined;
if (pane) {
lastInteractedPaneRef.current.set(tabId, {
kind: 'side-panel',
paneId: pane.id,
tool: pane.tool,
});
}
focusSidePanelPaneForTab(tabId, paneId);
}, [focusSidePanelPaneForTab]);
}, [focusSidePanelPaneForTab, sidePanelLayoutsRef]);
const handleTerminalPaneInteraction = useCallback((tabId: string, sessionId: string) => {
lastInteractedPaneRef.current.set(tabId, { kind: 'terminal', sessionId });
}, []);
const handleMagnifyTerminalPane = useCallback((tabId: string, sessionId: string) => {
const target: PaneMagnificationTarget = { kind: 'terminal', sessionId };
lastInteractedPaneRef.current.set(tabId, target);
setMagnifiedPane((current) => (
current?.tabId === tabId
&& current.target.kind === 'terminal'
&& current.target.sessionId === sessionId
? null
: { tabId, target }
));
}, []);
const handleMagnifySidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
const layout = sidePanelLayoutsRef.current.get(tabId);
const pane = layout
? collectSidePanelPanes(layout.root).find((candidate) => candidate.id === paneId)
: undefined;
if (!pane) return;
const target: PaneMagnificationTarget = {
kind: 'side-panel',
paneId: pane.id,
tool: pane.tool,
};
lastInteractedPaneRef.current.set(tabId, target);
setMagnifiedPane((current) => (
current?.tabId === tabId
&& current.target.kind === 'side-panel'
&& current.target.paneId === paneId
? null
: { tabId, target }
));
}, [sidePanelLayoutsRef]);
const handleRestoreMagnifiedPane = useCallback(() => {
const tabId = activeTabIdRef.current;
setMagnifiedPane((current) => current?.tabId === tabId ? null : current);
}, []);
useEffect(() => {
if (!paneMagnificationRef) return undefined;
const getTarget = (): { tabId: string; target: PaneMagnificationTarget; focused: boolean } | null => {
const tabId = activeTabStore.getActiveTabId();
if (!tabId) return null;
const workspace = workspacesRef.current.find((candidate) => candidate.id === tabId);
const hasCurrentMagnification = magnifiedPaneRef.current?.tabId === tabId;
if (workspace?.viewMode === 'focus' && !hasCurrentMagnification) return null;
const workspaceSessionIds = workspace ? collectSessionIds(workspace.root) : [];
const standaloneSession = sessionsRef.current.find((candidate) => (
candidate.id === tabId && !candidate.workspaceId
));
const terminalSessionIds = workspaceSessionIds.length > 0
? workspaceSessionIds
: standaloneSession ? [standaloneSession.id] : [];
const layout = sidePanelLayoutsRef.current.get(tabId);
const sidePanelPanes = layout ? collectSidePanelPanes(layout.root) : [];
const focusedSidePanelPane = sidePanelPanes.find((candidate) => (
candidate.id === layout?.focusedPaneId
));
const candidate = resolvePaneMagnificationCandidate({
tabId,
terminalSessionIds,
focusedSessionId: workspace?.focusedSessionId,
sidePanelPanes: focusedSidePanelPane
? [focusedSidePanelPane, ...sidePanelPanes.filter((pane) => pane !== focusedSidePanelPane)]
: sidePanelPanes,
lastTarget: lastInteractedPaneRef.current.get(tabId),
current: magnifiedPaneRef.current,
});
return candidate ? { tabId, ...candidate } : null;
};
const controller: PaneMagnificationController = {
getState: () => {
const target = getTarget();
return target ? (target.focused ? 'focused' : 'focusable') : 'unavailable';
},
focus: () => {
const target = getTarget();
if (!target || target.focused) return false;
setMagnifiedPane({ tabId: target.tabId, target: target.target });
return true;
},
restore: () => {
const target = getTarget();
if (!target?.focused) return false;
setMagnifiedPane(null);
return true;
},
toggle: () => {
const target = getTarget();
if (!target) return false;
setMagnifiedPane(target.focused ? null : { tabId: target.tabId, target: target.target });
return true;
},
};
paneMagnificationRef.current = controller;
return () => {
if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null;
};
}, [paneMagnificationRef, sidePanelLayoutsRef]);
const handleSplitSidePanelPane = useCallback((
tool: SidePanelTab,
@@ -1421,6 +1582,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const closingPane = layout
? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId)
: undefined;
setMagnifiedPane((current) => (
current?.tabId === tabId
&& current.target.kind === 'side-panel'
&& current.target.paneId === paneId
? null
: current
));
const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId);
if (closesWholePanel) {
handleCloseSidePanel();
@@ -2037,6 +2205,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleOpenSystem,
handleOpenNotes,
handleFocusSidePanelPane,
handleMagnifySidePanelPane,
handleRestoreMagnifiedPane,
handleMagnifyTerminalPane,
handleTerminalPaneInteraction,
handleSplitSidePanelPane,
handleCloseSidePanelPane,
handleResizeSidePanelSplit,
@@ -2093,6 +2265,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
lastSidePanelTabRef,
mountedAiTabIds: aiMountedTabIds,
mountedSftpTabIds,
magnifiedPane,
notesMountedTabIds,
notesOpenNoteByTab,
scriptsMountedTabIds,
+18
View File
@@ -6,10 +6,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
canPromoteTextEditor,
getTextEditorContentStats,
isTextEditorCommandWEnabled,
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
@@ -49,3 +51,19 @@ test("counts editor content without allocating line arrays", () => {
assert.deepEqual(getTextEditorContentStats(""), { lineCount: 1, charCount: 0 });
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
});
test("Monaco Cmd+W follows the current close-tab binding", () => {
const closeTabBinding = DEFAULT_KEY_BINDINGS.find((binding) => binding.action === "closeTab");
assert.ok(closeTabBinding);
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: true }), true);
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: false }), true);
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: true }), false);
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: false }), false);
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "disabled", closeTabBinding, isMac: true }), false);
assert.equal(isTextEditorCommandWEnabled({
hotkeyScheme: "mac",
closeTabBinding: { ...closeTabBinding, mac: "⌘ + E" },
isMac: true,
}), false);
});
+26
View File
@@ -21,9 +21,11 @@ const monacoBasePath = viteEnv.DEV
? './node_modules/monaco-editor/min/vs'
: `${viteEnv.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
const isMacPlatform = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
import { useI18n } from '../../application/i18n/I18nProvider';
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
@@ -116,6 +118,23 @@ export function getTextEditorContentStats(content: string): { lineCount: number;
return { lineCount, charCount: content.length };
}
export function isTextEditorCommandWEnabled({
hotkeyScheme,
closeTabBinding,
isMac,
}: {
hotkeyScheme: HotkeyScheme;
closeTabBinding?: KeyBinding;
isMac: boolean;
}): boolean {
if (hotkeyScheme === 'disabled' || !closeTabBinding) return false;
return isPrimaryModifierWBinding(
hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc,
matchesKeyBinding,
isMac,
);
}
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
@@ -166,6 +185,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => void>(() => {});
const handleCloseRef = useRef<(() => void) | null>(null);
const closeTabCommandWEnabledRef = useRef(false);
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
@@ -173,6 +193,11 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
closeTabCommandWEnabledRef.current = isTextEditorCommandWEnabled({
hotkeyScheme,
closeTabBinding,
isMac: isMacPlatform,
});
const handleSave = useCallback(() => {
if (saving) return;
@@ -268,6 +293,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
// key-event dispatcher fires first for focused editor keystrokes, so
// registering the command here is the reliable path.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
if (!closeTabCommandWEnabledRef.current) return;
handleCloseRef.current?.();
});
@@ -0,0 +1,3 @@
import "./noteMath.scss";
export { InlineMarkdownEditor } from "./InlineMarkdownEditor";
+419 -42
View File
@@ -1,18 +1,219 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import {
annotateNoteCodeBlockDeleteButtons,
createNoteDecorationMutationScheduler,
getHostPickerTriggerRange,
getNoteDecorationMutationDelay,
getRenderedNoteHeadingText,
isNoteSmallImageWidth,
isNoteMathLanguageLabel,
isSupportedNoteExternalHref,
isPointerInsideLinkActionHoverZone,
invokeNoteEditorDialogAction,
linkActionStatesEqual,
NOTE_SMALL_IMAGE_MAX_WIDTH,
NOTE_EDIT_DECORATION_DEBOUNCE_MS,
resolveHostPickerPopupPosition,
scrollNoteHeadingIntoView,
shouldApplyExternalNoteMarkdown,
shouldRenderNoteMathFormula,
shouldInsertClipboardTextAsMarkdown,
shouldHandleHostPickerNavigationKey,
} from "./InlineMarkdownEditor.tsx";
import { getSourceEditDelta, shouldCoalesceSourceUndoStep } from "./NoteSourceEditor.tsx";
test("source undo history coalesces only adjacent edits of the same typing kind", () => {
const previous = { inputType: "insertText", at: 1_000, caret: 2 };
const adjacentInsert = getSourceEditDelta("ab", "abc");
const movedCaretInsert = getSourceEditDelta("ab", "axb");
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, adjacentInsert), true);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, movedCaretInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_751, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "deleteContentBackward", 1_100, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertFromPaste", 1_100, adjacentInsert), false);
assert.equal(shouldCoalesceSourceUndoStep(null, "insertText", 1_100, adjacentInsert), false);
});
test("math language detection does not mistake plain text blocks for TeX", () => {
assert.equal(isNoteMathLanguageLabel("math"), false);
assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), false);
assert.equal(isNoteMathLanguageLabel("language-latex"), true);
assert.equal(isNoteMathLanguageLabel("language-tex highlighted"), true);
assert.equal(isNoteMathLanguageLabel("latex"), true);
assert.equal(isNoteMathLanguageLabel("tex"), true);
assert.equal(isNoteMathLanguageLabel("公式"), false);
assert.equal(isNoteMathLanguageLabel("text"), false);
assert.equal(isNoteMathLanguageLabel("plaintext"), false);
assert.equal(isNoteMathLanguageLabel("typescript"), false);
assert.equal(shouldRenderNoteMathFormula("Plain text"), false);
assert.equal(shouldRenderNoteMathFormula("plaintext"), false);
assert.equal(shouldRenderNoteMathFormula(""), false);
assert.equal(shouldRenderNoteMathFormula("math"), false);
assert.equal(shouldRenderNoteMathFormula("latex"), true);
assert.equal(shouldRenderNoteMathFormula("tex"), true);
});
test("live note decoration scans are debounced while preview mounts stay immediate", () => {
assert.equal(getNoteDecorationMutationDelay("edit"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
assert.equal(getNoteDecorationMutationDelay("live"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
assert.equal(getNoteDecorationMutationDelay("preview"), 0);
let nextId = 1;
let runCount = 0;
const timers = new Map<number, () => void>();
const frames = new Map<number, () => void>();
const runtime = {
requestFrame: (callback: () => void) => {
const id = nextId++;
frames.set(id, callback);
return id;
},
cancelFrame: (id: number) => { frames.delete(id); },
setTimer: (callback: () => void, delay: number) => {
assert.equal(delay, NOTE_EDIT_DECORATION_DEBOUNCE_MS);
const id = nextId++;
timers.set(id, callback);
return id;
},
clearTimer: (id: number) => { timers.delete(id); },
};
const editScheduler = createNoteDecorationMutationScheduler("edit", () => { runCount += 1; }, runtime);
editScheduler.schedule();
editScheduler.schedule();
editScheduler.schedule();
assert.equal(runCount, 0);
assert.equal(timers.size, 1);
const pendingEdit = [...timers.values()][0];
timers.clear();
pendingEdit();
assert.equal(runCount, 1);
editScheduler.schedule();
assert.equal(timers.size, 1);
const cancelledEdit = [...timers.values()][0];
editScheduler.cancel();
assert.equal(timers.size, 0);
cancelledEdit();
assert.equal(runCount, 1);
const previewScheduler = createNoteDecorationMutationScheduler("preview", () => { runCount += 1; }, runtime);
previewScheduler.schedule();
previewScheduler.schedule();
assert.equal(frames.size, 1);
const pendingPreview = [...frames.values()][0];
frames.clear();
pendingPreview();
assert.equal(runCount, 2);
});
test("note link and image actions open the editor dialogs", () => {
const opened: string[] = [];
const dialogs = {
openImageDialog: () => opened.push("image"),
openLinkDialog: () => opened.push("link"),
};
assert.equal(invokeNoteEditorDialogAction("link", dialogs), true);
assert.equal(invokeNoteEditorDialogAction("image", dialogs), true);
assert.equal(invokeNoteEditorDialogAction("bold", dialogs), false);
assert.equal(invokeNoteEditorDialogAction("link", null), false);
assert.deepEqual(opened, ["link", "image"]);
});
test("toolbar text selection is restricted to the note editor", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /if \(domText\) \{/);
assert.match(source, /domSelection && domSelection\.rangeCount > 0 && container/);
assert.match(source, /container\.contains\(range\.startContainer\)/);
assert.match(source, /container\.contains\(range\.endContainer\)/);
assert.match(source, /return domText;[\s\S]*return "";[\s\S]*querySelector\("\[contenteditable\]"\)/);
});
test("note outline jumps to the matching rendered heading", () => {
let scrollOptions: ScrollIntoViewOptions | undefined;
const first = {
tagName: "H1",
textContent: "Quoted",
scrollIntoView: () => undefined,
} as unknown as HTMLElement;
const second = {
tagName: "H2",
textContent: "Real",
scrollIntoView: (options?: ScrollIntoViewOptions) => {
scrollOptions = options;
},
} as unknown as HTMLElement;
const root = {
querySelectorAll: (selector: string) => {
assert.match(selector, /\.netcatty-mdx-content h1/);
assert.match(selector, /\.netcatty-mdx-content h6/);
return [first, second];
},
};
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Real" }), true);
assert.deepEqual(scrollOptions, {
behavior: "smooth",
block: "start",
inline: "nearest",
});
assert.equal(scrollNoteHeadingIntoView(root, { level: 3, text: "Missing" }), false);
assert.equal(scrollNoteHeadingIntoView(null, { level: 1, text: "None" }), false);
});
test("note outline matches rendered whitespace and strikethrough heading text", () => {
let scrolled = false;
const renderedHeading = {
tagName: "H2",
textContent: "Removed\n title",
scrollIntoView: () => {
scrolled = true;
},
} as unknown as HTMLElement;
const root = {
querySelectorAll: () => [renderedHeading],
};
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Removed title" }), true);
assert.equal(scrolled, true);
});
test("note outline matches image alt text inside rendered headings", () => {
const textNode = (textContent: string) => ({ nodeType: 3, textContent, childNodes: [] });
const imageNode = (alt: string) => ({
nodeType: 1,
tagName: "IMG",
textContent: "",
childNodes: [],
getAttribute: (name: string) => name === "alt" ? alt : null,
});
let scrolled = 0;
const imageHeading = {
tagName: "H2",
textContent: "",
childNodes: [imageNode("Logo")],
scrollIntoView: () => { scrolled += 1; },
} as unknown as HTMLElement;
const mixedHeading = {
tagName: "H2",
textContent: "Release notes",
childNodes: [textNode("Release "), imageNode("Logo"), textNode(" notes")],
scrollIntoView: () => { scrolled += 1; },
} as unknown as HTMLElement;
const root = { querySelectorAll: () => [imageHeading, mixedHeading] };
assert.equal(getRenderedNoteHeadingText(imageHeading), "Logo");
assert.equal(getRenderedNoteHeadingText(mixedHeading), "Release Logo notes");
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Logo" }), true);
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Release Logo notes" }), true);
assert.equal(scrolled, 2);
});
test("host picker navigation keys are handled even before a query is typed", () => {
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowDown", 3), true);
@@ -74,6 +275,35 @@ test("small note image width threshold matches README badge sizes", () => {
assert.equal(isNoteSmallImageWidth(null), false);
});
test("note image actions use a bordered toolbar shown only on hover or focus", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\][\s\S]*?gap:\s*0\.0625rem;[\s\S]*?padding:\s*0\.125rem;[\s\S]*?opacity:\s*0;[\s\S]*?pointer-events:\s*none;[\s\S]*?border:\s*1px solid/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button,[\s\S]*?width:\s*1\.375rem;[\s\S]*?height:\s*1\.375rem;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button svg,[\s\S]*?width:\s*0\.875rem;[\s\S]*?height:\s*0\.875rem;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\]:hover \[class\*="_editImageToolbar_"\][\s\S]*?opacity:\s*1;[\s\S]*?pointer-events:\s*auto;/s,
);
assert.match(
styles,
/\[data-editor-block-type="image"\]:focus-within \[class\*="_editImageToolbar_"\]/,
);
assert.doesNotMatch(
styles,
/\[data-editor-block-type="image"\]\[data-note-img-size="sm"\] \[class\*="_editImageToolbar_"\]/,
);
});
test("host picker trigger range only covers the typed trigger and query", () => {
const text = "before\n\n@10.2.0.32";
const range = getHostPickerTriggerRange(text);
@@ -144,37 +374,43 @@ test("note editor registers a code block editor for pasted fenced code", () => {
test("note editor enables image plugin for remote markdown images", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
assert.match(source, /InsertImage/);
});
test("note editor exposes preview and edit modes with a markdown toolbar in edit mode", () => {
test("note editor exposes its modes from a borderless title-row dropdown", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
assert.match(source, /type NoteEditorMode = "edit" \| "preview"/);
assert.match(source, /toolbarPlugin\(\{\s*toolbarContents:/s);
assert.match(source, /type NoteEditorMode/);
// The app-owned NoteToolbar (not MDXEditor's toolbarPlugin) hosts the
// formatting controls; MDXEditor must not render its own toolbar.
assert.doesNotMatch(source, /toolbarPlugin\(/);
assert.match(toolbarSource, /onAction\?\.\("undo"\)/);
assert.match(toolbarSource, /onAction\?\.\("redo"\)/);
assert.match(toolbarSource, /<Undo2 size=\{14\} \/>/);
assert.match(toolbarSource, /<Redo2 size=\{14\} \/>/);
// Preview and edit both use MDXEditor (readOnly in preview).
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
assert.match(source, /key=\{editorMode\}/);
assert.match(source, /netcatty-mdx-editor--preview/);
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
assert.match(source, /<BlockTypeSelect \/>/);
assert.match(source, /<BoldItalicUnderlineToggles /);
assert.match(source, /<ListsToggle /);
assert.match(source, /<InsertCodeBlock \/>/);
assert.match(source, /<InsertTable \/>/);
assert.match(source, /editorMode = controlledEditorMode \?\? "edit"/);
assert.doesNotMatch(source, /data-note-mode-switch/);
assert.doesNotMatch(source, /absolute -top-9/);
assert.match(managerSource, /data-note-title-row/);
assert.match(managerSource, /data-note-mode-switch/);
assert.match(managerSource, /Glasses/);
assert.match(managerSource, /PencilLine/);
assert.match(managerSource, /setNoteEditorMode\(\(currentMode\) => \(/);
assert.match(managerSource, /currentMode === "edit" \? "preview" : "edit"/);
assert.match(toolbarSource, /data-note-mode-dropdown-trigger/);
assert.match(toolbarSource, /data-note-mode-option=\{option\.mode\}/);
assert.match(toolbarSource, /gap-1\.5 border-0 bg-transparent px-2/);
assert.match(toolbarSource, /<SelectContent align="end" className="w-max min-w-\[10rem\]">/);
assert.match(
toolbarSource,
/data-note-mode-option=\{option\.mode\}[\s\S]*?className="h-9 whitespace-nowrap"/,
);
assert.match(toolbarSource, /<Select value=\{normalizedMode\}/);
assert.doesNotMatch(toolbarSource, /data-note-mode-switch=/);
assert.match(managerSource, /data-note-title-row[\s\S]*?<NoteModeDropdown[\s\S]*?<NoteToolbar/);
assert.match(managerSource, /<NoteModeDropdown[\s\S]*?editorMode=\{noteEditorMode\}/);
assert.match(managerSource, /editorMode=\{noteEditorMode\}/);
assert.match(managerSource, /className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary\/70 hover:text-foreground"/);
assert.doesNotMatch(managerSource, /data-note-mode-switch[\s\S]{0,500}border/);
assert.doesNotMatch(`${source}\n${managerSource}`, /role="tablist"|role="tab"|renderModeButton/);
assert.doesNotMatch(`${source}\n${managerSource}`, /className="mb-2 flex shrink-0 items-center justify-end"/);
});
@@ -182,6 +418,7 @@ test("note editor exposes preview and edit modes with a markdown toolbar in edit
test("note markdown toolbar remains usable in narrow panes", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /MoreHorizontal|data-note-toolbar-more|netcatty-note-toolbar-more/);
@@ -195,29 +432,23 @@ test("note markdown toolbar remains usable in narrow panes", () => {
styles,
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
);
// The app-owned NoteToolbar scrolls horizontally when the pane is narrow so
// formatting buttons are never clipped; the scrollbar stays visible.
assert.match(
styles,
/\.netcatty-note-markdown-toolbar\s*\{[^}]*max-width:\s*100%;[^}]*height:\s*auto\s*!important;[^}]*overflow:\s*visible\s*!important;/s,
toolbarSource,
/overflow-x-auto [^"]*\[scrollbar-width:thin\]/,
);
assert.match(
styles,
/\.netcatty-note-markdown-toolbar\s*\{[^}]*box-sizing:\s*border-box;[^}]*display:\s*flex\s*!important;[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
toolbarSource,
/\[&::-webkit-scrollbar\]:h-1\.5/,
);
assert.match(
styles,
/\.netcatty-note-markdown-toolbar\s*>\s*\*\s*\{[^}]*flex-shrink:\s*0;/s,
toolbarSource,
/\[&::-webkit-scrollbar-thumb\]:bg-border\/70/,
);
assert.match(
styles,
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*gap:\s*0\.125rem\s*!important;/s,
);
assert.match(
styles,
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
);
assert.match(
styles,
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s+button,[\s\S]*\.netcatty-note-markdown-toolbar\s+\[role="button"\][\s\S]*height:\s*1\.75rem\s*!important;/s,
toolbarSource,
/flex flex-1 items-center gap-0\.5 min-w-0 overflow-x-auto/,
);
});
@@ -291,15 +522,23 @@ test("note code block frame is borderless and language picker is compact", () =>
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*padding:\s*0\s*!important;/s,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;/s,
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*padding:\s*0\.125rem;/s,
/\.netcatty-mdx-content\s+pre\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;[^}]*padding:\s*0;/s,
);
assert.match(
styles,
/\.netcatty-note-code-copy\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*box-shadow:\s*none\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*absolute\s*!important;/s,
);
assert.match(
styles,
@@ -307,7 +546,11 @@ test("note code block frame is borderless and language picker is compact", () =>
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*fit-content\s*!important;/s,
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\] \[class\*="_tooltipTrigger_"\]\s*\{[^}]*display:\s*inline-flex\s*!important;[^}]*align-items:\s*center\s*!important;[^}]*align-self:\s*center\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0\s*!important;/s,
);
assert.match(
styles,
@@ -331,7 +574,7 @@ test("note code block frame is borderless and language picker is compact", () =>
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.3rem\s+0\s+0\.65rem;/s,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.25rem\s+0\s+0\.55rem;/s,
);
assert.match(
styles,
@@ -339,7 +582,7 @@ test("note code block frame is borderless and language picker is compact", () =>
);
assert.match(
styles,
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*padding:\s*0\s*!important;/s,
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
@@ -347,6 +590,29 @@ test("note code block frame is borderless and language picker is compact", () =>
);
});
test("note formulas render without framed surfaces", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
assert.doesNotMatch(styles, /data-language="math"/);
assert.match(
styles,
/\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;[^}]*border:\s*0;/s,
);
assert.match(
styles,
/\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;[^}]*padding:\s*0\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s,
);
assert.match(styles, /\.netcatty-math-formula-preview\s*\{[^}]*justify-content:\s*safe center;[^}]*overflow-x:\s*auto;/s);
assert.match(
styles,
/\.netcatty-math-reading-mode\s*>\s*\.netcatty-note-code-copy\s*\{[^}]*display:\s*none\s*!important;/s,
);
});
test("getCodeMirrorBlockText reads rendered code block lines", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
@@ -364,6 +630,62 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
assert.match(source, /onCopy\(text\)/);
});
test("annotateNoteCodeBlockCopyButtons treats a CSS-hidden toolbar as absent so preview shows the copy button", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /getComputedStyle\(toolbar\)\.display !== "none"/);
assert.match(source, /toolbarVisible/);
assert.match(source, /firstButton\.parentElement !== wrapper/);
assert.match(source, /wrapper\.appendChild\(firstButton\)/);
});
test("annotateNoteCodeBlockDeleteButtons swaps the delete icon only once", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const dom = new JSDOM(`
<div id="container">
<div class="_codeMirrorToolbar_test">
<button class="_toolbarCodeBlockLanguageSelectTrigger_test"></button>
<button id="delete"></button>
</div>
</div>
`);
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, "HTMLElement");
Object.defineProperty(globalThis, "HTMLElement", {
configurable: true,
value: dom.window.HTMLElement,
});
try {
const container = dom.window.document.querySelector("#container") as HTMLElement;
const deleteButton = dom.window.document.querySelector("#delete") as HTMLButtonElement;
annotateNoteCodeBlockDeleteButtons(container);
const installedIcon = deleteButton.firstElementChild;
assert.equal(deleteButton.dataset.noteCodeDelete, "true");
assert.equal(installedIcon?.nodeName.toLowerCase(), "svg");
const observer = new dom.window.MutationObserver(() => {});
observer.observe(deleteButton, { attributes: true, childList: true, subtree: true });
annotateNoteCodeBlockDeleteButtons(container);
assert.equal(observer.takeRecords().length, 0);
assert.equal(deleteButton.firstElementChild, installedIcon);
observer.disconnect();
} finally {
if (previousHTMLElement) {
Object.defineProperty(globalThis, "HTMLElement", previousHTMLElement);
} else {
delete (globalThis as { HTMLElement?: unknown }).HTMLElement;
}
dom.window.close();
}
assert.match(source, /export const annotateNoteCodeBlockDeleteButtons/);
assert.match(source, /data-note-code-delete/);
assert.match(source, /DELETE_ICON_SVG/);
assert.match(source, /stroke="currentColor"/);
assert.match(source, /stroke-width="2"/);
});
test("note preview uses MDX readOnly with code-copy chrome", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
@@ -385,18 +707,73 @@ test("note preview uses MDX readOnly with code-copy chrome", () => {
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*justify-content:\s*flex-end;/s,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*justify-content:\s*flex-end;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s*\{[^}]*height:\s*1\.35rem\s*!important;/s,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+button[^{]*\{[^}]*height:\s*1\.4rem\s*!important;/s,
);
assert.match(
styles,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s+svg\s*\{[^}]*width:\s*14px\s*!important;/s,
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\][^{]*svg\s*\{[^}]*width:\s*10px\s*!important;/s,
);
});
test("annotateMathFormulaBlocks handles empty math blocks and avoids reading toolbar text", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /export const annotateMathFormulaBlocks/);
assert.match(source, /const text = getCodeMirrorBlockText\(wrapper\)\.trim\(\);/);
assert.match(source, /if \(!formulaSource\) \{/);
assert.match(source, /existingPreview\.remove\(\)/);
});
test("NoteSourceEditor manages local draft state to prevent cursor jumping on debounced commits", () => {
const source = readFileSync(new URL("./NoteSourceEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const \[localValue, setLocalValue\] = useState\(value\);/);
assert.match(source, /value=\{localValue\}/);
assert.match(source, /onChange=\{handleChange\}/);
assert.match(source, /prevNoteIdRef\.current/);
assert.match(source, /prevValueRef\.current/);
});
test("source mode compares raw markdown separately from display-normalized markdown", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /const latestSourceMarkdownRef = useRef\(value\)/);
assert.match(source, /markdown === latestSourceMarkdownRef\.current/);
assert.match(source, /latestMarkdownRef\.current = normalizeNotePublicAssetPaths\(markdown\)/);
assert.match(source, /setAcceptedSourceMarkdown\(markdown\)/);
assert.match(
source,
/<NoteSourceEditor[\s\S]*?value=\{noteId !== undefined && noteId !== noteIdRef\.current \? value : acceptedSourceMarkdown\}[\s\S]*?onChange=\{commitSourceMarkdown\}/,
);
});
test("raw source drafts are not overwritten by external values with equivalent display markdown", () => {
const base = {
latestMarkdown: "![x](/x.png)",
syncedMarkdown: "![x](/x.png)",
latestSourceMarkdown: "![x](/x.png)",
syncedSourceMarkdown: "![x](/public/x.png)",
};
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
nextSourceMarkdown: "# Remote",
}), false);
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
nextSourceMarkdown: "![x](/x.png)",
}), true);
assert.equal(shouldApplyExternalNoteMarkdown({
...base,
latestSourceMarkdown: "![x](/public/x.png)",
nextSourceMarkdown: "![x](/x.png)",
}), true);
});
File diff suppressed because it is too large. Load diff
+162
View File
@@ -0,0 +1,162 @@
import {
Copy,
FileText,
Share2,
} from "lucide-react";
import React, { useCallback, useState, useRef, useEffect } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
type VaultNote,
} from "../../domain/notes";
import { copyToClipboard } from "../keychain/utils";
import { toast } from "../ui/toast";
export interface NoteExportMenuProps {
note: VaultNote | null;
allNotes: VaultNote[];
onExportNote: (note: VaultNote) => void;
onExportAll: () => void;
className?: string;
}
export const NoteExportMenu: React.FC<NoteExportMenuProps> = ({
note,
allNotes,
onExportNote,
onExportAll,
className = "",
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeAndRestoreFocus = useCallback(() => {
setOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
}, []);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeAndRestoreFocus();
return;
}
if (e.key === "Tab") {
setOpen(false);
return;
}
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
);
if (!items.length) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
let next = current;
if (e.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
items[next]?.focus();
};
window.addEventListener("mousedown", handleClickOutside);
window.addEventListener("keydown", handleKeyDown);
requestAnimationFrame(() => {
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
});
return () => {
window.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeAndRestoreFocus, open]);
const handleExportSingleMarkdown = () => {
if (!note) return;
onExportNote(note);
closeAndRestoreFocus();
};
const handleCopyMarkdown = async () => {
if (!note) return;
const ok = await copyToClipboard(note.content);
if (ok) {
toast.success(t("common.copied") || "已复制到剪贴板");
}
closeAndRestoreFocus();
};
const handleExportAllZip = () => {
if (!allNotes.length) return;
onExportAll();
closeAndRestoreFocus();
};
return (
<div className={`relative inline-block ${className}`} ref={menuRef}>
<button
ref={triggerRef}
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("notes.export.share")}
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="menu"
aria-expanded={open}
>
<Share2 size={16} />
</button>
{open && (
<div role="menu" className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
{note && (
<>
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("notes.export.currentNote")}
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportSingleMarkdown}
>
<FileText size={14} className="text-primary" />
<span>{t("notes.export.exportMarkdown")}</span>
</button>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleCopyMarkdown}
>
<Copy size={14} className="text-muted-foreground" />
<span>{t("notes.export.copyMarkdown")}</span>
</button>
<div className="my-1 border-t border-border" />
</>
)}
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("notes.export.allNotes")}
</div>
<button
type="button"
role="menuitem"
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
onClick={handleExportAllZip}
disabled={!allNotes.length}
>
<FileText size={14} className="text-emerald-500" />
<span>{t("notes.export.exportAllZip")}</span>
</button>
</div>
)}
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { NoteOutline } from "./NoteOutline.tsx";
test("note outline renders a borderless hierarchy without heading-level labels", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-CN">
<NoteOutline
content={"## 第一节\n\n### 子章节\n\n## 第二节"}
onClose={() => undefined}
onSelectHeading={() => undefined}
/>
</I18nProvider>,
);
assert.match(markup, /data-note-outline="true"/);
assert.equal(markup.match(/data-note-outline-item=/g)?.length, 3);
assert.match(markup, /padding-left:10px/);
assert.match(markup, /padding-left:22px/);
assert.doesNotMatch(markup, />H[1-6]</);
assert.doesNotMatch(markup, /border-l|border-b|uppercase|tracking-wider/);
});
+94
View File
@@ -0,0 +1,94 @@
import { ListTree, X } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes";
import { useI18n } from "../../application/i18n/I18nProvider";
export interface NoteOutlineProps {
content: string;
onSelectHeading?: (heading: NoteHeadingItem, index: number) => void;
onClose?: () => void;
className?: string;
}
export const NoteOutline: React.FC<NoteOutlineProps> = ({
content,
onSelectHeading,
onClose,
className = "",
}) => {
const { t } = useI18n();
const headings = useMemo(() => extractNoteHeadings(content), [content]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const minimumHeadingLevel = useMemo(
() => headings.length > 0
? Math.min(...headings.map((heading) => heading.level))
: 1,
[headings],
);
useEffect(() => {
setActiveHeadingId(null);
}, [content]);
return (
<nav
aria-label={t("notes.outline.title")}
data-note-outline="true"
className={`flex h-full flex-col bg-background select-none ${className}`}
>
<div className="flex shrink-0 items-center justify-between px-3.5 pb-2 pt-3">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground/90">
<ListTree size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{t("notes.outline.title")}</span>
<span className="shrink-0 text-[11px] font-normal tabular-nums text-muted-foreground/60">
{headings.length}
</span>
</div>
{onClose && (
<button
type="button"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{headings.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
<p>{t("notes.outline.empty")}</p>
<p className="mt-1 text-[11px] opacity-70">
{t("notes.outline.emptyHint")}
</p>
</div>
) : (
headings.map((item, index) => (
<button
key={item.id}
type="button"
data-note-outline-item={item.id}
data-heading-level={item.level}
className={`flex w-full items-center rounded-md py-1.5 pr-2 text-left text-xs leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 ${
activeHeadingId === item.id
? "bg-secondary/60 font-medium text-foreground"
: "text-muted-foreground hover:bg-secondary/40 hover:text-foreground"
}`}
style={{ paddingLeft: `${10 + Math.min(item.level - minimumHeadingLevel, 4) * 12}px` }}
onClick={() => {
setActiveHeadingId(item.id);
onSelectHeading?.(item, index);
}}
title={item.text}
>
<span className="truncate">{item.text}</span>
</button>
))
)}
</div>
</nav>
);
};
+265
View File
@@ -0,0 +1,265 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
NoteSourceEditor,
type NoteSourceEditorHandle,
} from "./NoteSourceEditor.tsx";
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const changes: string[] = [];
const textareaNode = {
selectionStart: 1,
selectionEnd: 1,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value="a"
onChange={(nextValue) => changes.push(nextValue)}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
const textarea = renderer!.root.findByType("textarea");
await act(async () => {
textarea.props.onChange({
target: { value: "ab" },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
textarea.props.onChange({
target: { value: "abc" },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
let prevented = false;
await act(async () => {
renderer!.root.findByType("textarea").props.onKeyDown({
key: "z",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: true,
preventDefault: () => { prevented = true; },
});
});
const composedTextarea = renderer!.root.findByType("textarea");
await act(async () => {
composedTextarea.props.onCompositionStart();
composedTextarea.props.onChange({
target: { value: "abc你", selectionStart: 4 },
nativeEvent: { inputType: "insertCompositionText" },
});
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionEnd();
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionStart();
renderer!.root.findByType("textarea").props.onChange({
target: { value: "abc你好", selectionStart: 5 },
nativeEvent: { inputType: "insertCompositionText" },
});
});
await act(async () => {
renderer!.root.findByType("textarea").props.onCompositionEnd();
});
await act(async () => editorRef.current?.insertAction("undo"));
await act(async () => editorRef.current?.insertAction("undo"));
assert.equal(prevented, true);
assert.deepEqual(changes, ["ab", "abc", "a", "abc", "abc你", "abc你好", "abc你", "abc"]);
assert.equal(renderer!.root.findByType("textarea").props.value, "abc");
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});
test("NoteSourceEditor keeps formatting and later typing as separate undo steps", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const changes: string[] = [];
const textareaNode = {
selectionStart: 0,
selectionEnd: 0,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value=""
onChange={(nextValue) => changes.push(nextValue)}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
await act(async () => {
editorRef.current?.insertAction("bold");
});
const formatted = "**bold text**";
const edited = "**bold text!**";
const textarea = renderer!.root.findByType("textarea");
await act(async () => {
textarea.props.onChange({
target: { value: edited },
nativeEvent: { inputType: "insertText" },
});
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
await act(async () => {
editorRef.current?.insertAction("undo");
});
assert.deepEqual(changes, [formatted, edited, formatted, ""]);
assert.equal(renderer!.root.findByType("textarea").props.value, "");
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});
test("NoteSourceEditor undo and redo restore toolbar formatting selections", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
actEnvironment.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
const editorRef = React.createRef<NoteSourceEditorHandle>();
const textareaNode = {
selectionStart: 0,
selectionEnd: 5,
focus: () => undefined,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
scrollTop: 0,
scrollTo: () => undefined,
};
let renderer: ReactTestRenderer | null = null;
try {
await act(async () => {
renderer = create(
<NoteSourceEditor
ref={editorRef}
noteId="note-1"
value="hello"
onChange={() => undefined}
/>,
{
createNodeMock: (element) => element.type === "textarea"
? textareaNode
: { scrollTop: 0 },
},
);
});
await act(async () => editorRef.current?.insertAction("bold"));
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 2, end: 7 },
);
await act(async () => editorRef.current?.insertAction("undo"));
assert.equal(renderer!.root.findByType("textarea").props.value, "hello");
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 0, end: 5 },
);
await act(async () => editorRef.current?.insertAction("redo"));
assert.equal(renderer!.root.findByType("textarea").props.value, "**hello**");
assert.deepEqual(
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
{ start: 2, end: 7 },
);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
actEnvironment.requestAnimationFrame = previousAnimationFrame;
}
});
+374
View File
@@ -0,0 +1,374 @@
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { type MarkdownActionType, wrapMarkdownSyntax } from "../../domain/notes";
const SOURCE_EDIT_UNDO_COALESCE_MS = 750;
interface SourceHistorySnapshot {
value: string;
selectionStart: number;
selectionEnd: number;
}
export const shouldCoalesceSourceUndoStep = (
previous: { inputType: string; at: number; caret: number } | null,
inputType: string,
now: number,
edit: { start: number; removedLength: number; insertedLength: number },
): boolean => {
if (!previous || previous.inputType !== inputType || now - previous.at > SOURCE_EDIT_UNDO_COALESCE_MS) {
return false;
}
if (inputType === "insertText") {
return edit.removedLength === 0 && edit.start === previous.caret;
}
if (inputType === "deleteContentBackward") {
return edit.insertedLength === 0 && edit.start + edit.removedLength === previous.caret;
}
if (inputType === "deleteContentForward") {
return edit.insertedLength === 0 && edit.start === previous.caret;
}
return false;
};
export const getSourceEditDelta = (
previousValue: string,
nextValue: string,
): { start: number; removedLength: number; insertedLength: number } => {
let start = 0;
const sharedLength = Math.min(previousValue.length, nextValue.length);
while (start < sharedLength && previousValue[start] === nextValue[start]) start += 1;
let previousEnd = previousValue.length;
let nextEnd = nextValue.length;
while (
previousEnd > start
&& nextEnd > start
&& previousValue[previousEnd - 1] === nextValue[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return {
start,
removedLength: previousEnd - start,
insertedLength: nextEnd - start,
};
};
export interface NoteSourceEditorHandle {
insertAction: (action: MarkdownActionType) => void;
focus: () => void;
scrollToLine: (line: number) => boolean;
}
export interface NoteSourceEditorProps {
noteId?: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
className?: string;
noteFontFamily?: string;
noteFontSize?: number;
}
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const lineNumbersRef = useRef<HTMLDivElement>(null);
const [localValue, setLocalValue] = useState(value);
const prevNoteIdRef = useRef(noteId);
const prevValueRef = useRef(value);
// Undo/redo history for the source textarea (native textarea undo is
// unreliable once the value is controlled by React).
const undoStackRef = useRef<SourceHistorySnapshot[]>([]);
const redoStackRef = useRef<SourceHistorySnapshot[]>([]);
const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null);
const compositionBaselineRef = useRef<SourceHistorySnapshot | null>(null);
const skipNextCompositionCommitRef = useRef(false);
const resetUserEditCoalescing = () => {
lastUserEditRef.current = null;
};
const createSnapshot = (
snapshotValue = localValue,
selectionStart = textareaRef.current?.selectionStart ?? snapshotValue.length,
selectionEnd = textareaRef.current?.selectionEnd ?? selectionStart,
): SourceHistorySnapshot => ({
value: snapshotValue,
selectionStart: Math.min(selectionStart, snapshotValue.length),
selectionEnd: Math.min(selectionEnd, snapshotValue.length),
});
// Adopt the external value only for genuine external changes:
// - noteId switch → always adopt (new note).
// - prop value changed AND differs from localValue → external edit.
// The parent debounces our own edits and echoes them back with
// value === localValue; those echoes must NOT reset the textarea, or the
// keystroke would be reverted and the caret would jump to the end.
useEffect(() => {
if (noteId !== prevNoteIdRef.current) {
prevNoteIdRef.current = noteId;
prevValueRef.current = value;
setLocalValue(value);
undoStackRef.current = [];
redoStackRef.current = [];
compositionBaselineRef.current = null;
skipNextCompositionCommitRef.current = false;
resetUserEditCoalescing();
return;
}
if (value !== prevValueRef.current) {
prevValueRef.current = value;
if (value !== localValue) {
setLocalValue(value);
undoStackRef.current = [];
redoStackRef.current = [];
compositionBaselineRef.current = null;
skipNextCompositionCommitRef.current = false;
resetUserEditCoalescing();
}
}
}, [noteId, value, localValue]);
const lineCount = (localValue.match(/\n/g)?.length || 0) + 1;
const lineNumbers = useMemo(
() => Array.from({ length: lineCount }, (_, i) => i + 1).join("\n"),
[lineCount],
);
const gutterWidth = Math.max(48, String(lineCount).length * 9 + 24);
const handleScroll = () => {
if (textareaRef.current && lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = e.target.value;
if (nextValue === localValue) return;
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
const inputType = typeof nativeInputType === "string" && nativeInputType
? nativeInputType
: "input";
const isCompositionInput = inputType === "insertCompositionText"
|| inputType === "insertFromComposition";
if (isCompositionInput) {
if (inputType === "insertFromComposition" && skipNextCompositionCommitRef.current) {
skipNextCompositionCommitRef.current = false;
setLocalValue(nextValue);
onChange(nextValue);
return;
}
if (compositionBaselineRef.current === null) {
const edit = getSourceEditDelta(localValue, nextValue);
const baseline = createSnapshot(
localValue,
edit.start,
edit.start + edit.removedLength,
);
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
redoStackRef.current = [];
}
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
resetUserEditCoalescing();
setLocalValue(nextValue);
onChange(nextValue);
return;
}
compositionBaselineRef.current = null;
const now = Date.now();
const edit = getSourceEditDelta(localValue, nextValue);
if (!shouldCoalesceSourceUndoStep(lastUserEditRef.current, inputType, now, edit)) {
const previousSelectionStart = inputType === "deleteContentBackward"
? edit.start + edit.removedLength
: edit.start;
undoStackRef.current.push(createSnapshot(
localValue,
previousSelectionStart,
edit.start + edit.removedLength,
));
}
const caret = typeof e.target.selectionStart === "number"
? e.target.selectionStart
: edit.start + edit.insertedLength;
lastUserEditRef.current = { inputType, at: now, caret };
redoStackRef.current = [];
setLocalValue(nextValue);
onChange(nextValue);
};
const applyHistoryAction = (action: "undo" | "redo"): boolean => {
const textarea = textareaRef.current;
if (!textarea) return false;
const stack = action === "undo" ? undoStackRef.current : redoStackRef.current;
const target = stack.pop();
if (target === undefined) return false;
resetUserEditCoalescing();
compositionBaselineRef.current = null;
const current = createSnapshot();
if (action === "undo") {
redoStackRef.current.push(current);
} else {
undoStackRef.current.push(current);
}
setLocalValue(target.value);
onChange(target.value);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(target.selectionStart, target.selectionEnd);
});
return true;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = textareaRef.current;
if (!textarea) return;
const key = e.key.toLowerCase();
const commandModifier = e.metaKey || e.ctrlKey;
const historyAction = commandModifier && !e.altKey
? key === "z"
? (e.shiftKey ? "redo" : "undo")
: key === "y" && e.ctrlKey
? "redo"
: null
: null;
if (historyAction) {
e.preventDefault();
applyHistoryAction(historyAction);
return;
}
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
resetUserEditCoalescing();
}
// Handle Tab insertion
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
undoStackRef.current.push(createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
setLocalValue(nextValue);
onChange(nextValue);
requestAnimationFrame(() => {
textarea.selectionStart = start + 2;
textarea.selectionEnd = start + 2;
});
}
};
useImperativeHandle(ref, () => ({
insertAction: (action: MarkdownActionType) => {
const textarea = textareaRef.current;
if (!textarea) return;
if (action === "undo" || action === "redo") {
applyHistoryAction(action);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const result = wrapMarkdownSyntax(localValue, start, end, action);
if (result.text !== localValue) {
undoStackRef.current.push(createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
}
setLocalValue(result.text);
onChange(result.text);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(result.selectionStart, result.selectionEnd);
});
},
focus: () => {
textareaRef.current?.focus();
},
scrollToLine: (line: number) => {
const textarea = textareaRef.current;
if (!textarea) return false;
const top = Math.max(0, (Math.max(1, line) - 1) * 24 - 12);
if (typeof textarea.scrollTo === "function") {
textarea.scrollTo({ top, behavior: "smooth" });
} else {
textarea.scrollTop = top;
}
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = top;
}
return true;
},
}));
return (
<div
className={`relative flex h-full w-full bg-background font-mono text-sm select-text overflow-hidden ${className}`}
>
{/* Line numbers gutter */}
<div
ref={lineNumbersRef}
style={{ width: `${gutterWidth}px` }}
className="shrink-0 py-3 select-none text-right pr-3 text-muted-foreground/40 border-r border-border/50 overflow-hidden font-mono text-sm leading-6"
onWheel={(e) => {
if (textareaRef.current) {
textareaRef.current.scrollTop += e.deltaY;
}
}}
>
<pre className="m-0 whitespace-pre font-inherit leading-6">{lineNumbers}</pre>
</div>
{/* Source Textarea */}
<div className="relative flex-1 h-full min-w-0">
<textarea
ref={textareaRef}
value={localValue}
onChange={handleChange}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
onPointerDown={resetUserEditCoalescing}
onCompositionStart={() => {
skipNextCompositionCommitRef.current = false;
if (compositionBaselineRef.current === null) {
const baseline = createSnapshot();
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
redoStackRef.current = [];
}
resetUserEditCoalescing();
}}
onCompositionEnd={() => {
const baseline = compositionBaselineRef.current;
if (baseline !== null && baseline.value === localValue) {
undoStackRef.current.pop();
}
skipNextCompositionCommitRef.current = baseline !== null && baseline.value !== localValue;
compositionBaselineRef.current = null;
resetUserEditCoalescing();
}}
placeholder={placeholder}
spellCheck={false}
style={{
fontFamily: noteFontFamily || undefined,
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
}}
className="w-full h-full py-3 px-4 bg-transparent text-foreground resize-none outline-none font-mono text-sm leading-6 whitespace-pre overflow-auto"
/>
</div>
</div>
);
},
);
NoteSourceEditor.displayName = "NoteSourceEditor";
+523
View File
@@ -0,0 +1,523 @@
import {
Bold,
Check,
CheckSquare,
Code,
Eye,
FileCode,
Heading1,
Heading2,
Heading3,
Heading4,
Heading,
Image as ImageIcon,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Minus,
PencilLine,
Quote,
Redo2,
Search,
Sigma,
SquareCode,
Strikethrough,
Table as TableIcon,
Type,
Underline,
Undo2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { type MarkdownActionType } from "../../domain/notes";
import { useAvailableFonts } from "../../application/state/fontStore";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { ActiveTextFormats, NoteEditorMode } from "./noteEditorTypes";
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select";
import { cn } from "../../lib/utils";
export interface NoteToolbarProps {
editorMode: NoteEditorMode;
onAction?: (action: MarkdownActionType) => void;
onOpenHostPicker?: () => void;
className?: string;
noteFontFamily?: string;
onChangeNoteFontFamily?: (font: string) => void;
noteFontSize?: number;
onChangeNoteFontSize?: (size: number) => void;
noteCodeFontSize?: number;
onChangeNoteCodeFontSize?: (size: number) => void;
/** Active text-format toggles at the current selection (button highlight). */
activeFormats?: ActiveTextFormats;
}
export interface NoteModeDropdownProps {
editorMode: NoteEditorMode;
onChangeMode: (mode: NoteEditorMode) => void;
className?: string;
}
const FONT_SIZES = [12, 13, 14, 15, 16, 18, 20];
const CODE_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18];
export const NoteModeDropdown: React.FC<NoteModeDropdownProps> = ({
editorMode,
onChangeMode,
className = "",
}) => {
const { t } = useI18n();
const normalizedMode: "edit" | "source" | "preview" =
editorMode === "live" ? "edit" : editorMode;
const options = [
{
mode: "edit" as const,
label: t("notes.toolbar.modeLive"),
title: t("notes.toolbar.modeLiveTitle"),
icon: PencilLine,
},
{
mode: "source" as const,
label: t("notes.toolbar.modeSource"),
title: t("notes.toolbar.modeSourceTitle"),
icon: SquareCode,
},
{
mode: "preview" as const,
label: t("notes.toolbar.modePreview"),
title: t("notes.toolbar.modePreviewTitle"),
icon: Eye,
},
];
const activeOption = options.find((option) => option.mode === normalizedMode) ?? options[0];
const ActiveIcon = activeOption.icon;
return (
<Select value={normalizedMode} onValueChange={(mode) => onChangeMode(mode as NoteEditorMode)}>
<SelectTrigger
data-note-mode-dropdown-trigger
aria-label={activeOption.title}
className={cn(
"app-no-drag h-8 w-auto shrink-0 gap-1.5 border-0 bg-transparent px-2 text-xs text-muted-foreground shadow-none hover:bg-secondary/70 hover:text-foreground focus:ring-0",
className,
)}
>
<ActiveIcon size={15} />
<span>{activeOption.label}</span>
</SelectTrigger>
<SelectContent align="end" className="w-max min-w-[10rem]">
{options.map((option) => {
const Icon = option.icon;
return (
<SelectItem
key={option.mode}
value={option.mode}
data-note-mode-option={option.mode}
className="h-9 whitespace-nowrap"
>
<span className="flex items-center gap-2 whitespace-nowrap"><Icon size={14} />{option.label}</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};
export const NoteToolbar: React.FC<NoteToolbarProps> = ({
editorMode,
onAction,
className = "",
noteFontFamily = "",
onChangeNoteFontFamily,
noteFontSize = 14,
onChangeNoteFontSize,
noteCodeFontSize = 13,
onChangeNoteCodeFontSize,
activeFormats = EMPTY_ACTIVE_FORMATS,
}) => {
const { t } = useI18n();
const [fontSearch, setFontSearch] = useState("");
// The font tool only controls code block / inline code fonts, so it lists
// system monospace fonts (fontStore) rather than the UI font set.
const availableSystemFonts = useAvailableFonts();
const systemFontList = useMemo(() => {
const defaultOption = { label: t("notes.toolbar.defaultFont"), value: "" };
const list = availableSystemFonts.map((f) => ({
label: f.name,
value: f.family,
}));
return [defaultOption, ...list];
}, [availableSystemFonts, t]);
const filteredFonts = useMemo(() => {
if (!fontSearch.trim()) return systemFontList;
const query = fontSearch.trim().toLowerCase();
return systemFontList.filter(
(f) => f.label.toLowerCase().includes(query) || f.value.toLowerCase().includes(query),
);
}, [fontSearch, systemFontList]);
const isEditing = editorMode === "edit" || editorMode === "live" || editorMode === "source";
if (!isEditing) return null;
// Highlight style for toggles that are active at the current selection.
const formatButtonClass = (active: boolean) =>
cn(
"p-1.5 rounded-md transition-colors",
active
? "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
);
return (
<div
className={`flex items-center gap-1.5 px-3 py-1.5 border-b border-border/70 bg-card/40 text-xs select-none min-w-0 ${className}`}
>
{/* Formatting Tools (Available in Live Preview & Source Mode) */}
<div className="flex flex-1 items-center gap-0.5 min-w-0 overflow-x-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/70 [&::-webkit-scrollbar-track]:bg-transparent">
{/* Undo / Redo */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("undo")}
title={t("notes.toolbar.undo")}
>
<Undo2 size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("redo")}
title={t("notes.toolbar.redo")}
>
<Redo2 size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Code Font & Typography Settings Dropdown */}
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
title={t("notes.toolbar.typography")}
onMouseDown={(e) => e.preventDefault()}
>
<Type size={14} />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-64 p-2.5 space-y-2.5 z-50 text-xs shadow-lg">
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[11px] font-medium text-muted-foreground">
<span>{t("notes.toolbar.customCodeFont")}</span>
<span className="text-[10px] opacity-70">
{t("notes.toolbar.fontsAvailable", { count: systemFontList.length })}
</span>
</div>
{/* Search Bar */}
<div className="relative flex items-center">
<Search size={12} className="absolute left-2 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder={t("notes.toolbar.searchFont")}
value={fontSearch}
onChange={(e) => setFontSearch(e.target.value)}
className="w-full pl-6 pr-2 py-1 rounded border border-border bg-background text-[11px] text-foreground outline-none focus:border-primary placeholder:text-muted-foreground/60"
/>
</div>
{/* Scrollable Font List */}
<div className="space-y-0.5 max-h-52 overflow-y-auto pr-1">
{filteredFonts.length === 0 ? (
<div className="py-2 text-center text-muted-foreground text-[11px]">
{t("notes.toolbar.noFontsFound")}
</div>
) : (
filteredFonts.map((f) => (
<button
key={f.value}
type="button"
style={{ fontFamily: f.value || undefined }}
className={cn(
"w-full px-2 py-1 rounded text-left text-xs transition-colors flex items-center justify-between gap-1.5",
(noteFontFamily || "") === f.value
? "bg-primary text-primary-foreground font-medium"
: "hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteFontFamily?.(f.value)}
>
<span className="truncate">{f.label}</span>
{(noteFontFamily || "") === f.value && <Check size={12} className="shrink-0" />}
</button>
))
)}
</div>
</div>
<div className="border-t border-border/60 pt-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.bodyFontSize")}</div>
<div className="flex flex-wrap gap-1">
{FONT_SIZES.map((sz) => (
<button
key={sz}
type="button"
className={cn(
"px-2 py-0.5 rounded text-xs border transition-colors",
(noteFontSize || 14) === sz
? "bg-primary text-primary-foreground border-primary font-medium"
: "border-border hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteFontSize?.(sz)}
>
{sz}px
</button>
))}
</div>
</div>
<div className="border-t border-border/60 pt-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.codeFontSize")}</div>
<div className="flex flex-wrap gap-1">
{CODE_FONT_SIZES.map((sz) => (
<button
key={sz}
type="button"
className={cn(
"px-2 py-0.5 rounded text-xs border transition-colors font-mono",
(noteCodeFontSize || 13) === sz
? "bg-primary text-primary-foreground border-primary font-medium"
: "border-border hover:bg-secondary text-foreground",
)}
onClick={() => onChangeNoteCodeFontSize?.(sz)}
>
{sz}px
</button>
))}
</div>
</div>
</DropdownContent>
</Dropdown>
{/* Heading Dropdown (Using Portal Dropdown to avoid clipping) */}
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
className="flex items-center gap-1 p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
title={t("notes.toolbar.headingLevel")}
>
<Heading size={14} />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-32 py-1 z-50 text-xs">
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h1")}
>
<Heading1 size={14} className="text-primary" />
<span>{t("notes.toolbar.h1")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h2")}
>
<Heading2 size={14} className="text-primary" />
<span>{t("notes.toolbar.h2")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h3")}
>
<Heading3 size={14} className="text-primary" />
<span>{t("notes.toolbar.h3")}</span>
</button>
<button
type="button"
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("h4")}
>
<Heading4 size={14} className="text-primary" />
<span>{t("notes.toolbar.h4")}</span>
</button>
</DropdownContent>
</Dropdown>
{/* Inline Styles */}
<button
type="button"
className={formatButtonClass(activeFormats.bold)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("bold")}
title={t("notes.toolbar.bold")}
>
<Bold size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.italic)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("italic")}
title={t("notes.toolbar.italic")}
>
<Italic size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.strikethrough)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("strikethrough")}
title={t("notes.toolbar.strikethrough")}
>
<Strikethrough size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.underline)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("underline")}
title={t("notes.toolbar.underline")}
>
<Underline size={14} />
</button>
<button
type="button"
className={formatButtonClass(activeFormats.code)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("code")}
title={t("notes.toolbar.inlineCode")}
>
<Code size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Lists */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("bullet")}
title={t("notes.toolbar.bulletList")}
>
<List size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("number")}
title={t("notes.toolbar.orderedList")}
>
<ListOrdered size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("task")}
title={t("notes.toolbar.taskList")}
>
<CheckSquare size={14} />
</button>
<div className="h-4 w-px bg-border mx-1 shrink-0" />
{/* Blocks */}
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("quote")}
title={t("notes.toolbar.quote")}
>
<Quote size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("codeblock")}
title={t("notes.toolbar.codeBlock")}
>
<FileCode size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("math")}
title={t("notes.toolbar.mathFormula")}
>
<Sigma size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("table")}
title={t("notes.toolbar.table")}
>
<TableIcon size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("divider")}
title={t("notes.toolbar.divider")}
>
<Minus size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("link")}
title={t("notes.toolbar.link")}
>
<LinkIcon size={14} />
</button>
<button
type="button"
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction?.("image")}
title={t("notes.toolbar.image")}
>
<ImageIcon size={14} />
</button>
</div>
</div>
);
};
+5 -1
View File
@@ -180,7 +180,11 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
);
assert.match(
editorSource,
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
/shouldApplyExternalNoteMarkdown/,
);
assert.match(
editorSource,
/syncedSourceMarkdownRef/,
);
});
+76
View File
@@ -1,10 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { readStoredStringValue } from "../../application/state/useStoredString.ts";
import { STORAGE_KEY_VAULT_NOTES_EDITOR_MODE } from "../../infrastructure/config/storageKeys.ts";
import type { VaultNote } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import {
@@ -17,6 +20,8 @@ import {
getValidatedNoteSelectionState,
getSelectedVaultNote,
isNoteFolderTreeSelected,
isNoteEditorMode,
normalizeNoteEditorMode,
NotesManager,
} from "./NotesManager.tsx";
@@ -82,6 +87,45 @@ test("NotesManager balances folder and note tree icon sizes", () => {
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
assert.match(markup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
assert.doesNotMatch(markup, /lucide lucide-file-text[^"]*mr-2/);
});
test("NotesManager gives folder and tag metadata pills the same compact style", () => {
const markup = renderNotes([note({ tags: ["inspection"] })]);
const document = new JSDOM(markup).window.document;
const folderPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="folder"]');
const tagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="tag"]');
const addTagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="add-tag"]');
assert.ok(folderPill);
assert.ok(tagPill);
assert.ok(addTagPill);
for (const className of [
"inline-flex",
"h-5",
"items-center",
"gap-1",
"rounded-md",
"bg-muted/70",
"px-2",
"text-[11px]",
"font-medium",
"leading-none",
]) {
assert.ok(folderPill.classList.contains(className), `folder pill should include ${className}`);
assert.ok(tagPill.classList.contains(className), `tag pill should include ${className}`);
assert.ok(addTagPill.classList.contains(className), `add-tag pill should include ${className}`);
}
assert.ok(tagPill.classList.contains("text-foreground"));
assert.ok(addTagPill.classList.contains("text-foreground"));
assert.equal(tagPill.classList.contains("border"), false);
assert.equal(tagPill.classList.contains("bg-primary/10"), false);
assert.equal(folderPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
assert.equal(tagPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
assert.ok([...folderPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
assert.ok([...tagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
assert.ok([...addTagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
});
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
@@ -114,6 +158,36 @@ test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
assert.equal(clampNotesTreeWidth(900), 520);
});
test("normalizeNoteEditorMode migrates the legacy live mode to edit", () => {
assert.equal(normalizeNoteEditorMode("live"), "edit");
assert.equal(isNoteEditorMode("live"), false);
assert.equal(isNoteEditorMode("edit"), true);
assert.equal(normalizeNoteEditorMode("preview"), "preview");
assert.equal(normalizeNoteEditorMode("invalid"), null);
});
test("stored legacy live mode falls back to edit on the real read path", (t) => {
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => key === STORAGE_KEY_VAULT_NOTES_EDITOR_MODE ? "live" : null,
} as Storage,
});
t.after(() => {
if (previousLocalStorage) {
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
} else {
Reflect.deleteProperty(globalThis, "localStorage");
}
});
assert.equal(
readStoredStringValue(STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, "edit", isNoteEditorMode),
"edit",
);
});
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
const noteSelection = getNoteSelectionState(notes[0], false);
@@ -351,4 +425,6 @@ test("NotesManager can re-open the same sidebar note when the request id changes
assert.match(source, /openNoteRequestId\?: number \| null/);
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
assert.match(source, /if \(isSidebarMode && overlayNoteView\)/);
assert.match(source, /\[isSidebarMode, overlayNoteView\]/);
});
+407 -102
View File
@@ -6,12 +6,13 @@ import {
FileText,
Folder,
FolderPlus,
Glasses,
Hash,
ListTree,
MoreHorizontal,
Minimize2,
PencilLine,
Plus,
Search,
Trash2,
Upload,
X,
} from "lucide-react";
@@ -20,6 +21,12 @@ import { useI18n } from "../../application/i18n/I18nProvider";
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
import { useStoredNumber } from "../../application/state/useStoredNumber";
import { useStoredString } from "../../application/state/useStoredString";
import { useAvailableFonts } from "../../application/state/fontStore";
import { resolveNoteFontFamily } from "../../domain/noteFonts";
import { NoteOutline } from "./NoteOutline";
import { NoteExportMenu } from "./NoteExportMenu";
import { NoteModeDropdown, NoteToolbar } from "./NoteToolbar";
import type { NoteSourceEditorHandle } from "./NoteSourceEditor";
import {
ancestorNoteGroupPaths,
buildVaultNoteMarkdownExportFiles,
@@ -35,11 +42,16 @@ import {
replaceNoteGroupPrefix,
resolveMovedNoteGroupPath,
sanitizeNoteExportFileNamePart,
type MarkdownActionType,
type NoteHeadingItem,
type VaultNotesExportScope,
} from "../../domain/notes";
import { getNextVaultOrder, reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from "../../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
} from "../../infrastructure/config/storageKeys";
import { logger } from "../../lib/logger";
@@ -76,16 +88,21 @@ import {
markVaultDropIndicator,
markVaultInsideDropIndicator,
} from "../vault/vaultReorderDrag";
import type { NoteEditorMode } from "./InlineMarkdownEditor";
import type {
ActiveTextFormats,
InlineMarkdownEditorHandle,
NoteEditorMode,
} from "./noteEditorTypes";
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
import { NoteTitleInput } from "./NoteTitleInput";
const InlineMarkdownEditor = lazy(() =>
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
import("./InlineMarkdownEditor.lazy").then((module) => ({ default: module.InlineMarkdownEditor })),
);
/** Warm the MDXEditor chunk before Suspense. */
export function prefetchInlineMarkdownEditor(): void {
void import("./InlineMarkdownEditor");
void import("./InlineMarkdownEditor.lazy");
}
interface NoteFolderNode {
@@ -99,6 +116,8 @@ type NotesToolbarPanel = "search" | null;
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
const noteMetadataPillClass = "inline-flex h-5 items-center gap-1 rounded-md bg-muted/70 px-2 text-[11px] font-medium leading-none";
const noteMetadataLabelClass = "translate-y-px";
const NOTES_TREE_DEFAULT_WIDTH = 300;
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
const NOTES_TREE_MIN_WIDTH = 160;
@@ -111,10 +130,14 @@ export function clampNotesTreeWidth(value: number): number {
}
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
value === "edit" || value === "preview" ? value : null;
value === "live"
? "edit"
: value === "edit" || value === "preview" || value === "source"
? value
: null;
const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
normalizeNoteEditorMode(value) !== null;
export const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
value === "edit" || value === "preview" || value === "source";
const InlineMarkdownEditorFallback = () => (
<div
@@ -372,6 +395,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
"edit",
isNoteEditorMode,
);
const [activeFormats, setActiveFormats] = useState<ActiveTextFormats>(EMPTY_ACTIVE_FORMATS);
const [overlayNoteId, setOverlayNoteId] = useState<string | null>(() => initialOpenNoteId);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(notes.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : [])),
@@ -388,11 +412,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
id: string;
name: string;
} | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [tagInputOpen, setTagInputOpen] = useState(false);
const [newTagText, setNewTagText] = useState("");
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
NOTES_TREE_DEFAULT_WIDTH,
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
);
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
"",
);
const availableNoteFonts = useAvailableFonts();
const resolvedNoteFontFamily = useMemo(
() => resolveNoteFontFamily(availableNoteFonts, noteFontFamily),
[availableNoteFonts, noteFontFamily],
);
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
14,
{ min: 10, max: 32 },
);
const handleSetNoteFontSize = useCallback((size: number) => {
setNoteFontSize(size);
persistNoteFontSize(size);
}, [persistNoteFontSize, setNoteFontSize]);
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
13,
{ min: 10, max: 32 },
);
const handleSetNoteCodeFontSize = useCallback((size: number) => {
setNoteCodeFontSize(size);
persistNoteCodeFontSize(size);
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
const treeAsideRef = useRef<HTMLElement | null>(null);
const treeWidthRef = useRef(treeWidth);
treeWidthRef.current = treeWidth;
@@ -418,6 +472,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return cleaned;
}, [onUpdateNotes]);
const addTagToNote = useCallback((noteId: string, tag: string) => {
const clean = tag.trim();
if (!clean) return;
commitNotes(sortedNotesRef.current.map((n) => {
if (n.id !== noteId) return n;
const existing = n.tags ?? [];
if (existing.includes(clean)) return n;
return { ...n, tags: [...existing, clean], updatedAt: Date.now() };
}));
}, [commitNotes]);
const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => {
commitNotes(sortedNotesRef.current.map((n) => {
if (n.id !== noteId) return n;
const existing = n.tags ?? [];
const nextTags = existing.filter((t) => t !== tagToRemove);
return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() };
}));
}, [commitNotes]);
const NOTE_DRAFT_DEBOUNCE_MS = 300;
const draftNoteIdRef = useRef<string | null>(null);
const draftTitleRef = useRef<string | null>(null);
@@ -523,18 +597,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}, [groupOrderByPath, groups, sortedNotes]);
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
? {
...selectedNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
}
: selectedNote;
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
? {
...overlayNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
}
: overlayNote;
const selectedNoteView = useMemo(() => {
if (!selectedNote) return null;
if (draftNoteId === selectedNote.id && draftTitle !== null) {
return { ...selectedNote, title: draftTitle };
}
return selectedNote;
}, [draftNoteId, draftTitle, selectedNote]);
const overlayNoteView = useMemo(() => {
if (!overlayNote) return null;
if (draftNoteId === overlayNote.id && draftTitle !== null) {
return { ...overlayNote, title: draftTitle };
}
return overlayNote;
}, [draftNoteId, draftTitle, overlayNote]);
useEffect(() => {
if (!draftNoteIdRef.current) return;
@@ -667,34 +744,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onOpenHost?.(host, { noteId });
}, [onOpenHost]);
const renderNoteModeToggle = () => {
const label = noteEditorMode === "edit" ? t("notes.mode.preview") : t("notes.mode.edit");
const Icon = noteEditorMode === "edit" ? Glasses : PencilLine;
const sourceEditorRef = useRef<NoteSourceEditorHandle>(null);
const inlineEditorRef = useRef<InlineMarkdownEditorHandle>(null);
const overlayEditorRef = useRef<InlineMarkdownEditorHandle>(null);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-note-mode-switch
aria-label={label}
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
onClick={() => {
flushNoteDraft();
setNoteEditorMode((currentMode) => (
currentMode === "edit" ? "preview" : "edit"
));
}}
>
<Icon size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
};
const handleToolbarAction = useCallback((action: MarkdownActionType) => {
if (isSidebarMode && overlayNoteView) {
overlayEditorRef.current?.executeAction(action);
} else {
inlineEditorRef.current?.executeAction(action);
}
}, [isSidebarMode, overlayNoteView]);
const handleOutlineHeadingSelect = useCallback((heading: NoteHeadingItem, index: number) => {
inlineEditorRef.current?.scrollToHeading(heading, index);
}, []);
const addNoteToGroup = (group: string | null) => {
let created: VaultNote | null = null;
@@ -861,24 +925,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
exportNotesToZip({ type: "group", group: groupPath }, groupPath);
}, [exportNotesToZip]);
const renderNoteExportButton = (note: VaultNote) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("notes.action.exportNote")}
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
onClick={() => exportNoteToMarkdown(note)}
>
<Download size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("notes.action.exportNote")}</TooltipContent>
</Tooltip>
);
const duplicateNoteById = (noteId: string) => {
flushNoteDraft();
const source = sortedNotesRef.current.find((note) => note.id === noteId);
@@ -1244,7 +1290,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
style={{ paddingLeft: depth * 16 + 4 }}
>
<div className="mr-1 h-5 w-4 shrink-0" />
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current">
<div className="mr-1 flex shrink-0 items-center text-current">
<Folder size={16} strokeWidth={1.9} />
</div>
<VaultTreeInlineRenameInput
@@ -1274,7 +1320,8 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
renameNoteFromTree(note.id, name);
}}
onRenameCancel={() => setEditingNoteId(null)}
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
icon={<FileText size={16} className="shrink-0 text-muted-foreground" />}
iconClassName="mr-1"
data-note-id={note.id}
data-notes-drag-kind="note"
data-notes-context-menu="note"
@@ -1351,6 +1398,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
selected={isNoteFolderTreeSelected(selectedGroup, selectedNoteId, node.path)}
hasChildren={hasChildren}
iconSize={16}
iconClassName="mr-1"
editing={editingGroupPath === node.path}
editingInitialName={node.name}
onRenameCommit={(name) => renameGroup(node.path, name)}
@@ -1742,7 +1790,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
>
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-8 pt-6 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<NoteTitleInput
noteId={selectedNoteView.id}
@@ -1754,35 +1802,226 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(selectedNoteView)}
{renderNoteModeToggle()}
</div>
<ScrollArea className="min-h-0 flex-1">
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
<NoteModeDropdown
editorMode={noteEditorMode}
onChangeMode={(mode) => {
flushNoteDraft();
setNoteEditorMode(mode);
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
/>
{/* Outline Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("notes.outline.title")}
className={cn(
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
showOutline ? "bg-secondary text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setShowOutline((prev) => !prev)}
>
<ListTree size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("notes.outline.title")}</TooltipContent>
</Tooltip>
{/* Export Menu */}
<NoteExportMenu
note={selectedNoteView}
allNotes={sortedNotes}
onExportNote={exportNoteToMarkdown}
onExportAll={exportAllNotes}
/>
{/* Delete Note */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("action.delete")}
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={() => requestDeleteNoteById(selectedNoteView.id)}
>
<Trash2 size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("action.delete")}</TooltipContent>
</Tooltip>
</div>
{/* Tags & Folder Breadcrumb Header */}
<div className="flex flex-wrap items-center gap-1.5 px-8 pb-2 text-xs text-muted-foreground">
{selectedNoteView.group && (
<span
data-note-metadata-pill="folder"
className={cn(noteMetadataPillClass, "text-foreground")}
>
<Folder size={12} className="text-amber-500" />
<span className={noteMetadataLabelClass}>{selectedNoteView.group}</span>
</span>
)}
{selectedNoteView.tags?.map((tag) => (
<span
key={tag}
data-note-metadata-pill="tag"
className={cn(noteMetadataPillClass, "text-foreground")}
>
<Hash size={12} className="opacity-70" />
<span className={noteMetadataLabelClass}>{tag}</span>
<button
type="button"
className="-mr-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
onClick={() => removeTagFromNote(selectedNoteView.id, tag)}
title={t("notes.tag.remove")}
>
<X size={10} />
</button>
</span>
))}
{tagInputOpen ? (
<div className="inline-flex h-5 items-center gap-1 rounded-md border border-primary/60 bg-background px-1.5 leading-none">
<Hash size={12} className="text-muted-foreground" />
<input
type="text"
autoFocus
value={newTagText}
onChange={(e) => setNewTagText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (newTagText.trim()) {
addTagToNote(selectedNoteView.id, newTagText.trim());
setNewTagText("");
setTagInputOpen(false);
}
} else if (e.key === "Escape") {
setTagInputOpen(false);
setNewTagText("");
}
}}
onBlur={() => {
if (newTagText.trim()) {
addTagToNote(selectedNoteView.id, newTagText.trim());
}
setTagInputOpen(false);
setNewTagText("");
}}
placeholder={t("notes.tag.placeholder")}
className="w-20 translate-y-px bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60"
/>
</div>
) : (
<button
type="button"
data-note-metadata-pill="add-tag"
className={cn(noteMetadataPillClass, "text-foreground transition-colors hover:bg-muted")}
onClick={() => setTagInputOpen(true)}
>
<Plus size={12} />
<span className={noteMetadataLabelClass}>{t("notes.tag.add")}</span>
</button>
)}
</div>
{/* Cherry Studio Style Note Toolbar */}
<NoteToolbar
editorMode={noteEditorMode}
onAction={handleToolbarAction}
activeFormats={activeFormats}
noteFontFamily={resolvedNoteFontFamily}
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
noteFontSize={noteFontSize}
onChangeNoteFontSize={handleSetNoteFontSize}
noteCodeFontSize={noteCodeFontSize}
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
/>
<div className="flex flex-1 min-h-0 min-w-0">
{noteEditorMode === "source" ? (
<div
className="flex-1 min-h-0 min-w-0 h-full"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
ref={inlineEditorRef}
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
</Suspense>
</LazyLoadBoundary>
</div>
) : (
<ScrollArea className="min-h-0 flex-1">
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
ref={inlineEditorRef}
noteId={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
)}
{showOutline && (
<div className="h-full w-64 shrink-0">
<NoteOutline
content={selectedNoteView.content}
onSelectHeading={handleOutlineHeadingSelect}
onClose={() => setShowOutline(false)}
/>
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center px-4">
@@ -1833,7 +2072,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-4 pt-5 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<NoteTitleInput
noteId={overlayNoteView.id}
@@ -1845,12 +2084,39 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(overlayNoteView)}
{renderNoteModeToggle()}
<NoteModeDropdown
editorMode={noteEditorMode}
onChangeMode={(mode) => {
flushNoteDraft();
setNoteEditorMode(mode);
}}
/>
<NoteExportMenu
note={overlayNoteView}
allNotes={sortedNotes}
onExportNote={exportNoteToMarkdown}
onExportAll={exportAllNotes}
/>
</div>
<ScrollArea className="min-h-0 flex-1">
{/* Note Toolbar in Overlay */}
<NoteToolbar
editorMode={noteEditorMode}
onAction={handleToolbarAction}
activeFormats={activeFormats}
noteFontFamily={resolvedNoteFontFamily}
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
noteFontSize={noteFontSize}
onChangeNoteFontSize={handleSetNoteFontSize}
noteCodeFontSize={noteCodeFontSize}
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
/>
{noteEditorMode === "source" ? (
<div
className="min-h-full w-full px-4 pt-2 pb-6"
className="flex-1 min-h-0 min-w-0 h-full"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
@@ -1860,6 +2126,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
ref={overlayEditorRef}
noteId={overlayNoteView.id}
value={overlayNoteView.content}
placeholder={t("notes.editor.placeholder")}
@@ -1869,11 +2136,49 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
) : (
<ScrollArea className="min-h-0 flex-1">
<div
className="min-h-full w-full px-4 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
ref={overlayEditorRef}
noteId={overlayNoteView.id}
value={overlayNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
previewEmptyLabel={t("notes.preview.empty")}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
</Suspense>
</LazyLoadBoundary>
</div>
</ScrollArea>
)}
</div>
</div>
)}
+26
View File
@@ -0,0 +1,26 @@
import type { MarkdownActionType, NoteHeadingItem } from "../../domain/notes";
export interface InlineMarkdownEditorHandle {
executeAction: (action: MarkdownActionType) => void;
focus: () => void;
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => boolean;
}
export type NoteEditorMode = "edit" | "preview" | "source" | "live";
/** Active text-format toggles at the current selection (toolbar highlight). */
export type ActiveTextFormats = {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
code: boolean;
};
export const EMPTY_ACTIVE_FORMATS: ActiveTextFormats = {
bold: false,
italic: false,
underline: false,
strikethrough: false,
code: false,
};
+5
View File
@@ -0,0 +1,5 @@
@use "katex/src/styles/katex.scss" with (
$font-folder: "katex/dist/fonts",
$use-woff: false,
$use-ttf: false,
);
+62
View File
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeNoteMathSource,
NOTE_MATH_KATEX_OPTIONS,
renderNoteMathFormula,
} from "./noteMathRenderer.ts";
test("note math renderer uses accessible, untrusted KaTeX output", () => {
assert.equal(NOTE_MATH_KATEX_OPTIONS.displayMode, true);
assert.equal(NOTE_MATH_KATEX_OPTIONS.output, "htmlAndMathml");
assert.equal(NOTE_MATH_KATEX_OPTIONS.throwOnError, false);
assert.equal(NOTE_MATH_KATEX_OPTIONS.trust, false);
const html = renderNoteMathFormula(String.raw`E = mc^2`);
assert.match(html, /class="katex-display"/);
assert.match(html, /class="katex-mathml"/);
assert.match(html, /<msup>/);
});
test("note math renderer handles prime shorthand and matrices", () => {
const primes = renderNoteMathFormula(String.raw`f'(x) + x''`);
assert.match(primes, /<msup>/);
assert.match(primes, /′/);
const matrix = renderNoteMathFormula(String.raw`\begin{pmatrix} a & b \\ c & d \end{pmatrix}^{-1}`);
assert.match(matrix, /<mtable(?:\s|>)/);
assert.match(matrix, /<msup>/);
});
test("note math renderer supports common LaTeX constructs without custom parsing", () => {
const html = renderNoteMathFormula(
String.raw`\sum_{i=1}^n \frac{\alpha_i}{\sqrt[3]{x_i}} \times \left\lVert v \right\rVert`,
);
assert.match(html, /∑/);
assert.match(html, /<mfrac>/);
assert.match(html, /<mroot>/);
assert.match(html, /α/);
assert.match(html, /∥/);
});
test("note math renderer accepts balanced outer display delimiters only", () => {
assert.equal(normalizeNoteMathSource(" $$ x^2 $$ "), "x^2");
assert.equal(normalizeNoteMathSource(String.raw`\[ x^2 \]`), "x^2");
assert.equal(normalizeNoteMathSource("$$ x^2"), "$$ x^2");
assert.equal(normalizeNoteMathSource("x^2 $$"), "x^2 $$");
});
test("note math renderer shows invalid input without throwing", () => {
const html = renderNoteMathFormula(String.raw`\frac{`);
assert.match(html, /class="katex-error"/);
assert.match(html, /\\frac/);
});
test("note math renderer blocks untrusted links and external images", () => {
const link = renderNoteMathFormula(String.raw`\href{javascript:alert(1)}{click}`);
assert.doesNotMatch(link, /href=/i);
const image = renderNoteMathFormula(String.raw`\includegraphics{https://example.com/x.png}`);
assert.doesNotMatch(image, /<img/i);
});
+27
View File
@@ -0,0 +1,27 @@
import katex, { type KatexOptions } from "katex";
export const NOTE_MATH_KATEX_OPTIONS: KatexOptions = Object.freeze({
displayMode: true,
output: "htmlAndMathml",
throwOnError: false,
strict: "warn",
trust: false,
maxExpand: 1_000,
maxSize: 20,
});
export const normalizeNoteMathSource = (source: string): string => {
const trimmed = source.trim();
if (trimmed.length >= 4 && trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
return trimmed.slice(2, -2).trim();
}
if (trimmed.length >= 4 && trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) {
return trimmed.slice(2, -2).trim();
}
return trimmed;
};
export const renderNoteMathFormula = (source: string): string => katex.renderToString(
normalizeNoteMathSource(source),
NOTE_MATH_KATEX_OPTIONS,
);
@@ -0,0 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("terminal font dropdown always preserves an explicit default option", () => {
const source = readFileSync(new URL("./TerminalFontSelect.tsx", import.meta.url), "utf8");
assert.match(source, /font\.id === "" \|\| font\.id === value/);
});
+1 -1
View File
@@ -51,7 +51,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
// the version (isFontInstalled reads module state).
void availabilityVersion;
const filtered = fonts.filter(
(font) => font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
(font) => font.id === "" || font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
);
if (hasAuthoritativeData()) return filtered;
return filtered.length >= 1 ? filtered : fonts;
@@ -1,19 +1,29 @@
import React, { memo, useCallback, useState } from "react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { useStoredString } from "../../../application/state/useStoredString";
import { useStoredNumber } from "../../../application/state/useStoredNumber";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
import { useAvailableFonts } from "../../../application/state/fontStore";
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY } from "../../../infrastructure/config/appIconVariants";
import { STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../infrastructure/config/storageKeys";
import {
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
} from "../../../infrastructure/config/storageKeys";
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
Dialog,
@@ -61,11 +71,40 @@ function SettingsAppearanceTab(props: {
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
const availableMonoFonts = useAvailableFonts();
const noteFontOptions = useMemo(() => [
{ id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const },
...availableMonoFonts,
], [availableMonoFonts, t]);
const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false);
const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean(
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
);
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
"",
);
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
14,
{ min: 10, max: 32 },
);
const handleSetNoteFontSize = useCallback((size: number) => {
setNoteFontSize(size);
persistNoteFontSize(size);
}, [persistNoteFontSize, setNoteFontSize]);
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
13,
{ min: 10, max: 32 },
);
const handleSetNoteCodeFontSize = useCallback((size: number) => {
setNoteCodeFontSize(size);
persistNoteCodeFontSize(size);
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
const {
theme,
resolvedTheme,
@@ -464,6 +503,59 @@ function SettingsAppearanceTab(props: {
onChange={setAutoImportSystemKnownHosts}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font"
label={t('settings.vault.notesFont')}
description={t('settings.vault.notesFontDesc')}
>
<TerminalFontSelect
value={resolveNoteFontSelectionId(noteFontOptions, noteFontFamily)}
fonts={noteFontOptions}
onChange={(v) => setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))}
className="w-48"
ariaLabel={t('settings.vault.notesFont')}
/>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-font-size"
label={t('settings.vault.notesFontSize')}
description={t('settings.vault.notesFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={12}
max={22}
step={1}
value={noteFontSize}
onChange={(e) => handleSetNoteFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteFontSize}px
</span>
</div>
</SettingRow>
<SettingRow
anchorId="appearance-vault-notes-code-font-size"
label={t('settings.vault.notesCodeFontSize')}
description={t('settings.vault.notesCodeFontSizeDesc')}
>
<div className="flex items-center gap-2">
<input
type="range"
min={10}
max={22}
step={1}
value={noteCodeFontSize}
onChange={(e) => handleSetNoteCodeFontSize(Number(e.target.value))}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{noteCodeFontSize}px
</span>
</div>
</SettingRow>
</div>
<SettingsAnchor anchorId="appearance-custom-css">
@@ -6,10 +6,13 @@ import { renderToStaticMarkup } from "react-dom/server";
import { normalizeTerminalSettings } from "../../../domain/models/terminal.ts";
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings.tsx";
const renderSettings = (autoCloseOnExit: boolean) => renderToStaticMarkup(
const renderSettings = (
autoCloseOnExit: boolean,
disconnectedNoticeMode: "terminal" | "dialog" = "terminal",
) => renderToStaticMarkup(
React.createElement(TerminalBehaviorSettings, {
t: (key: string) => key,
terminalSettings: normalizeTerminalSettings({ autoCloseOnExit }),
terminalSettings: normalizeTerminalSettings({ autoCloseOnExit, disconnectedNoticeMode }),
updateTerminalSetting: () => {},
}),
);
@@ -38,3 +41,13 @@ test("terminal behavior settings expose OSC desktop notification mode", () => {
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications/);
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications\.always/);
});
test("terminal behavior settings expose disconnected notice mode", () => {
const terminalMarkup = renderSettings(true, "terminal");
const dialogMarkup = renderSettings(true, "dialog");
assert.match(terminalMarkup, /settings-anchor-terminal-disconnected-notice/);
assert.match(terminalMarkup, /settings\.terminal\.behavior\.disconnectedNotice/);
assert.match(terminalMarkup, /settings\.terminal\.behavior\.disconnectedNotice\.terminal/);
assert.match(dialogMarkup, /settings\.terminal\.behavior\.disconnectedNotice\.dialog/);
});
@@ -1,6 +1,6 @@
import React from "react";
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
@@ -50,6 +50,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
/>
</SettingRow>
<SettingRow
anchorId="terminal-disconnected-notice"
label={t("settings.terminal.behavior.disconnectedNotice")}
description={t("settings.terminal.behavior.disconnectedNotice.desc")}
>
<Select
value={terminalSettings.disconnectedNoticeMode}
options={[
{ value: "terminal", label: t("settings.terminal.behavior.disconnectedNotice.terminal") },
{ value: "dialog", label: t("settings.terminal.behavior.disconnectedNotice.dialog") },
]}
onChange={(v) => updateTerminalSetting("disconnectedNoticeMode", v as DisconnectedNoticeMode)}
className="w-40"
/>
</SettingRow>
<SettingRow
anchorId="terminal-right-click"
label={t("settings.terminal.behavior.rightClick")}
@@ -0,0 +1,237 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
} from "../test-support/renderReactDom.tsx";
test("announces folder replacement risk and refocuses Merge for each queued conflict", async (t) => {
const env = installDomEnvironment();
const previousMutationObserver = globalThis.MutationObserver;
const previousNodeFilter = globalThis.NodeFilter;
const previousHTMLInputElement = globalThis.HTMLInputElement;
Object.defineProperty(globalThis, "MutationObserver", {
configurable: true,
writable: true,
value: env.window.MutationObserver,
});
Object.defineProperty(globalThis, "NodeFilter", {
configurable: true,
writable: true,
value: env.window.NodeFilter,
});
Object.defineProperty(globalThis, "HTMLInputElement", {
configurable: true,
writable: true,
value: env.window.HTMLInputElement,
});
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { SftpConflictDialog } = await import("./SftpConflictDialog.tsx");
const renderer = await createDomRenderer(env.document);
const resolvedActions: string[] = [];
t.after(async () => {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 20));
Object.defineProperty(globalThis, "MutationObserver", {
configurable: true,
writable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, "NodeFilter", {
configurable: true,
writable: true,
value: previousNodeFilter,
});
Object.defineProperty(globalThis, "HTMLInputElement", {
configurable: true,
writable: true,
value: previousHTMLInputElement,
});
env.cleanup();
});
const queuedConflicts = [
{
transferId: "folder-conflict",
fileName: "docs",
sourcePath: "/source/docs",
targetPath: "/destination/docs",
isDirectory: true,
existingType: "directory",
existingSize: 4096,
newSize: 4096,
existingModified: 1,
newModified: 2,
},
{
transferId: "next-folder-conflict",
fileName: "photos",
sourcePath: "/source/photos",
targetPath: "/destination/photos",
isDirectory: true,
existingType: "directory",
existingSize: 4096,
newSize: 4096,
existingModified: 3,
newModified: 4,
},
{
transferId: "symlink-folder-conflict",
fileName: "shortcut",
sourcePath: "/source/shortcut",
targetPath: "/destination/shortcut",
isDirectory: true,
existingType: "symlink",
existingSize: 0,
newSize: 4096,
existingModified: 5,
newModified: 6,
},
{
transferId: "file-folder-conflict",
fileName: "archive",
sourcePath: "/source/archive",
targetPath: "/destination/archive",
isDirectory: true,
existingType: "file",
existingSize: 1024,
newSize: 4096,
existingModified: 7,
newModified: 8,
},
{
transferId: "legacy-folder-conflict",
fileName: "legacy",
sourcePath: "/source/legacy",
targetPath: "/destination/legacy",
isDirectory: true,
existingType: undefined,
existingSize: 0,
newSize: 4096,
existingModified: 9,
newModified: 10,
},
{
transferId: "file-conflict",
fileName: "notes.txt",
sourcePath: "/source/notes.txt",
targetPath: "/destination/notes.txt",
isDirectory: false,
existingType: "file",
existingSize: 128,
newSize: 256,
existingModified: 11,
newModified: 12,
},
] satisfies React.ComponentProps<typeof SftpConflictDialog>["conflicts"];
const QueueHarness = () => {
const [conflicts, setConflicts] = React.useState(queuedConflicts);
return React.createElement(SftpConflictDialog, {
conflicts,
onResolve: (_id, action) => {
resolvedActions.push(action);
setConflicts((current) => current.slice(1));
},
formatFileSize: (size: number) => `${size} B`,
});
};
await renderer.render(React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(QueueHarness),
));
await flushEffects();
const dialog = env.document.querySelector<HTMLElement>("[role=dialog]");
assert.ok(dialog, "folder conflict dialog should render");
const describedBy = dialog.getAttribute("aria-describedby");
assert.ok(describedBy, "dialog should expose its safety description");
const describedText = describedBy
.split(/\s+/)
.map((id) => env.document.getElementById(id)?.textContent ?? "")
.join(" ");
assert.match(describedText, /Merge: keeps destination-only content/);
assert.match(describedText, /Replace: deletes destination-only content and cannot be undone/);
assert.doesNotMatch(env.document.body.textContent ?? "", /A folder with the same name already exists/);
const dialogTitle = env.document.querySelector("[role=dialog] h2");
assert.equal(dialogTitle?.textContent, "docs already exists");
assert.equal(dialogTitle?.getAttribute("aria-label"), "Folder Conflict: docs already exists");
const warning = env.document.getElementById(describedBy.split(/\s+/).at(-1) ?? "");
assert.ok(warning, "folder action guidance should render");
assert.equal(warning.querySelectorAll("svg").length, 2, "both guidance rows should use matching icons");
assert.equal(warning.querySelectorAll("p.text-xs").length, 0, "guidance rows should use the same text size");
const replaceButton = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === "Replace");
assert.ok(replaceButton, "folder replacement action should render");
const mergeButton = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === "Merge");
assert.ok(mergeButton, "folder merge action should render");
assert.match(mergeButton.className, /bg-primary/);
assert.match(mergeButton.className, /border/);
assert.doesNotMatch(replaceButton.className, /(^|\s)bg-destructive(?:\s|$)/);
assert.match(replaceButton.className, /text-destructive/);
assert.match(replaceButton.className, /border/);
assert.equal(replaceButton.getAttribute("aria-describedby"), describedBy.split(/\s+/).at(-1));
for (const label of ["Stop", "Skip", "Duplicate", "Merge", "Replace"]) {
const action = Array.from(env.document.querySelectorAll("button"))
.find((button) => button.textContent === label);
assert.ok(action, `${label} action should render`);
assert.match(action.className, /h-9/);
assert.match(action.className, /min-w-24/);
assert.match(action.className, /border/);
}
assert.equal(
Array.from(env.document.querySelectorAll("button"))
.filter((button) => button.className.includes("bg-primary"))
.length,
1,
"Merge should be the only visually primary action",
);
await dispatchDomEvent(replaceButton, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(resolvedActions, ["replace"]);
assert.match(env.document.body.textContent ?? "", /photos/);
assert.equal(env.document.activeElement?.textContent, "Merge");
const clickFocusedAction = async (expectedLabel: string) => {
const focusedButton = env.document.activeElement;
assert.equal(focusedButton?.textContent, expectedLabel);
assert.ok(focusedButton, `${expectedLabel} should be focused`);
await dispatchDomEvent(focusedButton, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
await new Promise((resolve) => setTimeout(resolve, 20));
};
await clickFocusedAction("Merge");
assert.match(env.document.body.textContent ?? "", /shortcut/);
assert.equal(env.document.activeElement?.textContent, "Replace");
await clickFocusedAction("Replace");
assert.match(env.document.body.textContent ?? "", /archive/);
assert.equal(env.document.activeElement?.textContent, "Duplicate");
await clickFocusedAction("Duplicate");
assert.match(env.document.body.textContent ?? "", /legacy/);
assert.match(env.document.body.textContent ?? "", /type could not be confirmed/);
assert.equal(env.document.activeElement?.textContent, "Duplicate");
assert.equal(
Array.from(env.document.querySelectorAll("button")).some((button) => button.textContent === "Replace"),
false,
);
await clickFocusedAction("Duplicate");
assert.match(env.document.body.textContent ?? "", /notes\.txt/);
assert.equal(env.document.activeElement?.textContent, "Replace");
await clickFocusedAction("Replace");
assert.deepEqual(resolvedActions, ["replace", "merge", "replace", "duplicate", "duplicate", "replace"]);
assert.equal(env.document.querySelector("[role=dialog]"), null);
});
+71 -1
View File
@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canReplaceConflict } from "./SftpConflictDialog.tsx";
import {
canReplaceConflict,
getSftpConflictDialogPresentation,
} from "./SftpConflictDialog.tsx";
test("does not offer replace when a file upload conflicts with an existing directory", () => {
assert.equal(canReplaceConflict({
@@ -37,3 +40,70 @@ test("offers replace when a file upload conflicts with an existing symlink", ()
existingType: "symlink",
}), true);
});
test("does not offer replace when a folder conflict has an unknown destination type", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: undefined,
}), false);
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: undefined,
});
assert.equal(presentation.descriptionKey, "sftp.conflict.folderUnknownDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.replaceVariant, "default");
});
test("makes merge the safe primary action for a same-named folder conflict", () => {
assert.deepEqual(getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "directory",
}), {
titleKey: "sftp.conflict.folderTitle",
descriptionKey: "sftp.conflict.folderDesc",
showFileMetadata: false,
showDirectoryReplaceWarning: true,
mergeVariant: "default",
replaceVariant: "outline",
});
});
test("keeps the existing file conflict presentation unchanged", () => {
assert.deepEqual(getSftpConflictDialogPresentation({
isDirectory: false,
existingType: "file",
}), {
titleKey: "sftp.conflict.title",
descriptionKey: "sftp.conflict.desc",
showFileMetadata: true,
showDirectoryReplaceWarning: false,
mergeVariant: "outline",
replaceVariant: "default",
});
});
test("does not show the directory deletion warning when replacing a symlink", () => {
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "symlink",
});
assert.equal(presentation.titleKey, "sftp.conflict.folderTitle");
assert.equal(presentation.descriptionKey, "sftp.conflict.folderSymlinkDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.replaceVariant, "default");
});
test("explains why a folder cannot merge with an existing file", () => {
const presentation = getSftpConflictDialogPresentation({
isDirectory: true,
existingType: "file",
});
assert.equal(presentation.titleKey, "sftp.conflict.folderTitle");
assert.equal(presentation.descriptionKey, "sftp.conflict.folderFileDesc");
assert.equal(presentation.showDirectoryReplaceWarning, false);
assert.equal(presentation.mergeVariant, "outline");
});
+158 -63
View File
@@ -2,8 +2,8 @@
* SFTP Conflict Resolution Dialog
*/
import { AlertCircle } from 'lucide-react';
import React, { memo, useState } from 'react';
import { AlertTriangle, GitMerge } from 'lucide-react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
import { Button } from '../ui/button';
@@ -28,6 +28,30 @@ export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' |
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
};
export const getSftpConflictDialogPresentation = (
conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>,
) => {
const isDestructiveDirectoryReplace = conflict.isDirectory && conflict.existingType === 'directory';
const descriptionKey = !conflict.isDirectory
? 'sftp.conflict.desc'
: conflict.existingType === 'file'
? 'sftp.conflict.folderFileDesc'
: conflict.existingType === 'symlink'
? 'sftp.conflict.folderSymlinkDesc'
: conflict.existingType === 'directory'
? 'sftp.conflict.folderDesc'
: 'sftp.conflict.folderUnknownDesc';
return {
titleKey: conflict.isDirectory ? 'sftp.conflict.folderTitle' : 'sftp.conflict.title',
descriptionKey,
showFileMetadata: !conflict.isDirectory,
showDirectoryReplaceWarning: isDestructiveDirectoryReplace,
mergeVariant: isDestructiveDirectoryReplace ? 'default' : 'outline',
replaceVariant: isDestructiveDirectoryReplace ? 'outline' : 'default',
} as const;
};
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
@@ -74,7 +98,39 @@ const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
const duplicateButtonRef = useRef<HTMLButtonElement>(null);
const mergeButtonRef = useRef<HTMLButtonElement>(null);
const replaceButtonRef = useRef<HTMLButtonElement>(null);
const previousConflictIdRef = useRef<string | undefined>(undefined);
const descriptionId = React.useId();
const directoryWarningId = React.useId();
const conflict = conflicts[0]; // Handle first conflict
const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory';
const currentCanReplace = conflict ? canReplaceConflict(conflict) : false;
useEffect(() => {
const currentConflictId = conflict?.transferId;
const previousConflictId = previousConflictIdRef.current;
previousConflictIdRef.current = currentConflictId;
if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return;
const nextAction = currentCanMerge
? mergeButtonRef.current
: currentCanReplace
? replaceButtonRef.current
: duplicateButtonRef.current;
if (!nextAction) return;
// If the previously focused action disappears or becomes disabled,
// Radix may restore focus to the first button after this effect. Focus
// on the next frame so the current conflict's safe action wins.
if (typeof globalThis.requestAnimationFrame === 'function') {
const frame = globalThis.requestAnimationFrame(() => nextAction.focus());
return () => globalThis.cancelAnimationFrame(frame);
}
const timer = globalThis.setTimeout(() => nextAction.focus(), 0);
return () => globalThis.clearTimeout(timer);
}, [conflict?.transferId, currentCanMerge, currentCanReplace]);
if (!conflict) return null;
@@ -86,8 +142,13 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const canReplace = canReplaceConflict(conflict);
const canMerge = currentCanMerge;
const canReplace = currentCanReplace;
const presentation = getSftpConflictDialogPresentation(conflict);
const showConflictDescription = !presentation.showDirectoryReplaceWarning;
const describedBy = presentation.showDirectoryReplaceWarning
? `${descriptionId} ${directoryWarningId}`
: descriptionId;
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
@@ -96,43 +157,63 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return (
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
<DialogContent className="gap-5 p-5 sm:max-w-[640px] sm:p-6">
<DialogHeader className="space-y-2 pr-8">
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<DialogContent
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
aria-describedby={describedBy}
>
<DialogHeader className="space-y-1.5 pr-8">
<DialogTitle
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-lg leading-tight"
aria-label={`${t(presentation.titleKey)}: ${conflict.fileName} ${t('sftp.conflict.alreadyExistsSuffix')}`}
>
<span className="min-w-0 break-words">{conflict.fileName}</span>
{' '}
<span className="text-base font-normal text-muted-foreground">
{t('sftp.conflict.alreadyExistsSuffix')}
</span>
{t('sftp.conflict.title')}
</DialogTitle>
<DialogDescription className="text-[15px] leading-6">
{t('sftp.conflict.desc')}
</DialogDescription>
<div id={descriptionId} className={showConflictDescription ? undefined : 'sr-only'}>
<DialogDescription className="leading-5">
{t(presentation.descriptionKey)}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
<div className="min-w-0 break-words">
<span className="font-medium text-foreground">{conflict.fileName}</span>
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
<div className="space-y-3">
{presentation.showFileMetadata && (
<div className="space-y-3">
<ConflictFileSummary
title={t('sftp.conflict.existingFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.existingSize)}
modified={formatDate(conflict.existingModified)}
/>
<ConflictFileSummary
title={t('sftp.conflict.newFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.newSize)}
modified={formatDate(conflict.newModified)}
/>
</div>
</div>
)}
<div className="space-y-3">
<ConflictFileSummary
title={t('sftp.conflict.existingFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.existingSize)}
modified={formatDate(conflict.existingModified)}
/>
<ConflictFileSummary
title={t('sftp.conflict.newFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.newSize)}
modified={formatDate(conflict.newModified)}
/>
</div>
{presentation.showDirectoryReplaceWarning && (
<div
id={directoryWarningId}
className="space-y-1.5 text-sm leading-5"
>
<div className="flex items-start gap-2 text-muted-foreground">
<GitMerge className="mt-0.5 h-4 w-4 shrink-0" />
<p>{t('sftp.conflict.folderMergeHint')}</p>
</div>
<div className="flex items-start gap-2 text-sm font-normal text-destructive/90">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{t('sftp.conflict.folderReplaceWarning')}</p>
</div>
</div>
)}
{sameTypeConflictCount > 1 && (
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
@@ -147,47 +228,61 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
)}
</div>
<DialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap sm:items-center sm:justify-end sm:space-x-0">
<DialogFooter className="flex flex-col-reverse gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
<Button
variant="outline"
size="sm"
onClick={() => handleAction('stop')}
className="min-w-24 shrink-0 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
className="min-w-24 self-start border-border/70 text-muted-foreground hover:text-destructive"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
className="min-w-24 shrink-0"
>
{t('sftp.conflict.action.skip')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('duplicate')}
className="min-w-24 shrink-0"
>
{t('sftp.conflict.action.duplicate')}
</Button>
{conflict.isDirectory && (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="min-w-24 shrink-0"
size="sm"
onClick={() => handleAction('skip')}
className="min-w-24 border-border/70"
>
{t('sftp.conflict.action.merge')}
{t('sftp.conflict.action.skip')}
</Button>
)}
{canReplace && (
<Button
variant="default"
onClick={() => handleAction('replace')}
className="min-w-28 shrink-0"
ref={duplicateButtonRef}
variant="outline"
size="sm"
onClick={() => handleAction('duplicate')}
className="min-w-24 border-border/70"
>
{t('sftp.conflict.action.replace')}
{t('sftp.conflict.action.duplicate')}
</Button>
)}
{conflict.isDirectory && (
<Button
ref={mergeButtonRef}
variant={presentation.mergeVariant}
size="sm"
onClick={() => handleAction('merge')}
disabled={!canMerge}
autoFocus={presentation.showDirectoryReplaceWarning}
className="min-w-24 border border-primary"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
{canReplace && (
<Button
ref={replaceButtonRef}
variant={presentation.replaceVariant}
size="sm"
onClick={() => handleAction('replace')}
aria-describedby={presentation.showDirectoryReplaceWarning ? directoryWarningId : undefined}
className={presentation.showDirectoryReplaceWarning
? 'min-w-24 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
: 'min-w-24'}
>
{t('sftp.conflict.action.replace')}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -239,3 +239,23 @@ test("shows restored session copy for disconnected restored placeholders", () =>
assert.match(markup, /This terminal is disconnected/);
assert.match(markup, /Reconnect/);
});
test("disconnected observer surfaces do not advertise a reconnect they cannot perform", () => {
const markup = renderDialog({
status: "disconnected",
error: "Observed session ended.",
showEnterReconnectHint: false,
progressProps: {
timeLeft: 0,
isCancelling: false,
progressLogs: [],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: undefined,
},
});
assert.equal(markup.includes("Press Enter to reconnect"), false);
assert.equal(markup.includes("Start over"), false);
assert.match(markup, /Close session/);
});
@@ -203,7 +203,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
}
event.preventDefault();
event.stopPropagation();
onRetry();
onRetry?.();
}, [canEnterReconnectFromDialog, onRetry]);
const targetFirstSegmentWidth = isVerifyingHostKey || shouldCompleteProgress
? 100
@@ -19,7 +19,7 @@ export interface TerminalConnectionProgressProps {
progressLogs: string[];
onCancelConnect: () => void;
onCloseSession: () => void;
onRetry: () => void;
onRetry?: () => void;
reconnectLabel?: string;
}
@@ -107,9 +107,11 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {reconnectLabel ?? t('terminal.progress.startOver')}
</Button>
{onRetry && (
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {reconnectLabel ?? t('terminal.progress.startOver')}
</Button>
)}
</div>
</div>
)}
+4 -1
View File
@@ -103,6 +103,7 @@ export interface TerminalToolbarProps {
onUpdateHost?: (host: Host) => void;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
// Search functionality
isSearchOpen?: boolean;
onToggleSearch?: () => void;
@@ -141,6 +142,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onUpdateHost,
showClose,
onClose,
closeLabel,
isSearchOpen,
onToggleSearch,
showLogButton = false,
@@ -1056,6 +1058,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
variant="ghost"
size="icon"
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
aria-label={closeLabel ?? t('terminal.toolbar.closeSession')}
onClick={(e) => {
e.stopPropagation();
onClose();
@@ -1064,7 +1067,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<X size={11} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.toolbar.closeSession')}</TooltipContent>
<TooltipContent side="bottom">{closeLabel ?? t('terminal.toolbar.closeSession')}</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
+155 -3
View File
@@ -1,11 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
formatTerminalHostInfoBarTitle,
formatTerminalHostInfoBarTooltip,
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
@@ -17,8 +20,143 @@ import {
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
shouldShowStatusBarConnectionControls,
TerminalDisconnectedNotice,
resolveTerminalDisconnectedNoticeMessage,
} from "./TerminalView.tsx";
test("terminal disconnected notice keeps the reason and reconnect hint on one compact row", () => {
const markup = renderToStaticMarkup(React.createElement(TerminalDisconnectedNotice, {
message: "Connection timed out.",
reconnectHint: "Press Enter to reconnect",
bottom: 4,
left: 4,
right: 4,
}));
assert.match(markup, /data-terminal-disconnected-notice="true"/);
assert.match(markup, /role="status"/);
assert.match(markup, /Connection timed out\./);
assert.match(markup, /Press Enter to reconnect/);
assert.match(markup, /h-7/);
assert.doesNotMatch(markup, /pointer-events-none/);
});
test("clicking the disconnected notice preserves terminal keyboard focus", () => {
let prevented = false;
let focused = false;
focusTerminalFromDisconnectedNotice(
{ preventDefault: () => { prevented = true; } },
() => { focused = true; },
);
assert.equal(prevented, true);
assert.equal(focused, true);
});
test("automatic reconnect notice uses explicit lifecycle copy instead of a stale log line", () => {
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "connecting",
error: null,
reconnectMessage: "Auto reconnect attempt 2...",
disconnectedLabel: "Disconnected",
}),
"Auto reconnect attempt 2...",
);
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "disconnected",
error: "Connection timed out.",
reconnectMessage: "Waiting for host key confirmation",
disconnectedLabel: "Disconnected",
}),
"Connection timed out.",
);
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "disconnected",
error: "Connection timed out.",
reconnectMessage: "Reconnecting...",
disconnectedLabel: "Disconnected",
isReconnectActive: true,
}),
"Reconnecting...",
);
});
test("automatic reconnect switches to attempt copy before waking a hibernated terminal", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf('const startReconnect = async');
const updateNotice = source.indexOf('setReconnectNoticeMessage(reconnectAttemptMessage)', startReconnect);
const wakeHibernated = source.indexOf('if (!termRef.current && hibernatedRef.current)', startReconnect);
assert.ok(startReconnect >= 0);
assert.ok(updateNotice > startReconnect);
assert.ok(wakeHibernated > updateNotice);
});
test("hibernated manual reconnect can continue after its runtime wakes", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf("const startReconnect = async");
const markManualReconnect = source.indexOf(
'setManualReconnectActive(mode === "manual")',
startReconnect,
);
const initialGuard = source.slice(startReconnect, markManualReconnect);
const clearWakeInFlight = source.indexOf(
"reconnectWakeInFlightRef.current = false",
markManualReconnect,
);
const continueReconnect = source.indexOf("startReconnectRef.current?.(mode)", clearWakeInFlight);
assert.ok(startReconnect >= 0);
assert.ok(markManualReconnect > startReconnect);
assert.match(initialGuard, /reconnectPreparationTokenRef\.current !== null/);
assert.match(initialGuard, /reconnectWakeInFlightRef\.current/);
assert.doesNotMatch(initialGuard, /manualReconnectActive/);
assert.ok(clearWakeInFlight > markManualReconnect);
assert.ok(continueReconnect > clearWakeInFlight);
});
test("manual reconnect publishes preparation without starting the connection timeout", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf("const startReconnect = async");
const publishPreparation = source.indexOf(
"terminalReconnectRegistry.setActive(sessionId, true)",
startReconnect,
);
const cleanupSession = source.indexOf(
"await cleanupSession({ retainOwnership: true })",
startReconnect,
);
const restoreDisconnected = source.indexOf(
'updateStatus("disconnected")',
startReconnect,
);
assert.ok(startReconnect >= 0);
assert.ok(publishPreparation > startReconnect);
assert.ok(cleanupSession > publishPreparation);
assert.equal(
source.indexOf('if (mode === "manual") updateStatus("connecting")', startReconnect),
-1,
);
assert.ok(restoreDisconnected > startReconnect);
assert.ok(restoreDisconnected < cleanupSession);
});
test("programmatic input during hibernated reconnect does not clear reconnect presentation", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const scrollStart = source.indexOf("const scrollToBottomAfterProgrammaticInput");
const scrollEnd = source.indexOf("useEffect(() =>", scrollStart);
const scrollBody = source.slice(scrollStart, scrollEnd);
assert.notEqual(scrollStart, -1);
assert.notEqual(scrollEnd, -1);
assert.doesNotMatch(scrollBody, /manualReconnectActiveRef|setReconnectNoticeMessage/);
});
test("line timestamp toggle creates a persistent host update", () => {
const host = {
id: "host-1",
@@ -111,6 +249,7 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
});
@@ -238,6 +377,18 @@ test("terminal title keeps the copy host action beside the address", () => {
assert.ok(copyAction < timestampToggle);
});
test("focus mode and temporary pane magnification use separate toolbar actions", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const focusAction = source.indexOf("onClick={onExpandToFocus}");
const magnifyAction = source.indexOf("onClick={onTogglePaneMagnification}");
assert.notEqual(focusAction, -1);
assert.notEqual(magnifyAction, -1);
assert.ok(focusAction < magnifyAction);
assert.match(source.slice(focusAction, magnifyAction), /terminal\.toolbar\.focusMode/);
assert.match(source.slice(magnifyAction), /terminal\.paneMagnification\.(restore|magnify)/);
});
test("popup terminals disable line timestamp controls", () => {
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
@@ -250,9 +401,10 @@ test("terminal body keeps a slight inset from the surrounding chrome", () => {
assert.match(source, /const terminalBodyInset = 4/);
assert.match(source, /left: activeLineTimestampGutterWidth \+ terminalBodyInset/);
assert.match(source, /right: terminalRightInset/);
assert.match(source, /bottom: terminalBodyInset/);
assert.match(source, /const terminalBottomInset = terminalBodyInset \+ \(showDisconnectedTerminalNotice \? 28 : 0\)/);
assert.match(source, /bottom: terminalBottomInset/);
assert.match(source, /left=\{terminalBodyInset\}/);
assert.match(source, /bottom=\{terminalBodyInset\}/);
assert.match(source, /bottom=\{terminalBottomInset\}/);
});
test("hidden host information bar gives its vertical space back to the terminal", () => {
@@ -311,7 +463,7 @@ test("hidden host information keeps terminal actions rendered", () => {
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
const controls = source.indexOf("{renderControls({ showClose: inWorkspace })}");
const controls = source.indexOf("{renderControls({ showClose: inWorkspace, restorePaneLayout: isPaneMagnified })}");
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
assert.notEqual(hostInfoStart, -1);
+140 -18
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -33,6 +33,77 @@ type HostLineTimestampToggle = {
showLineTimestamps?: boolean;
};
export function TerminalDisconnectedNotice({
message,
reconnectHint,
bottom,
left,
right,
onPointerDown,
}: {
message: string;
reconnectHint?: string;
bottom: number;
left: number;
right: number;
onPointerDown?: React.PointerEventHandler<HTMLDivElement>;
}) {
return (
<div
role="status"
aria-live="polite"
data-terminal-disconnected-notice="true"
className="absolute z-20 flex h-7 items-center gap-2 overflow-hidden rounded border px-2 text-[11px]"
onPointerDown={onPointerDown}
style={{
bottom,
left,
right,
color: 'var(--terminal-ui-fg)',
borderColor: 'var(--terminal-ui-border)',
backgroundColor: 'var(--terminal-ui-bg)',
}}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" title={message}>{message}</span>
{reconnectHint && (
<span
className="shrink-0"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg) 62%, transparent)' }}
>
{reconnectHint}
</span>
)}
</div>
);
}
export function focusTerminalFromDisconnectedNotice(
event: Pick<React.PointerEvent<HTMLDivElement>, "preventDefault">,
focusTerminal: () => void,
): void {
event.preventDefault();
focusTerminal();
}
export function resolveTerminalDisconnectedNoticeMessage({
status,
error,
reconnectMessage,
disconnectedLabel,
isReconnectActive = false,
}: {
status: 'connecting' | 'connected' | 'disconnected';
error?: string | null;
reconnectMessage?: string | null;
disconnectedLabel: string;
isReconnectActive?: boolean;
}): string {
return status === 'connecting' || isReconnectActive
? reconnectMessage || disconnectedLabel
: error || disconnectedLabel;
}
export function getLineTimestampToggleHostUpdate<T extends HostLineTimestampToggle>(
host: T,
): Pick<T, "id"> & { showLineTimestamps: boolean } {
@@ -116,6 +187,7 @@ export function shouldReconnectTerminalOnEnterKey({
needsAuth,
needsHostKeyVerification,
hasBlockingOverlay,
isReconnectActive = false,
altKey,
ctrlKey,
metaKey,
@@ -129,6 +201,7 @@ export function shouldReconnectTerminalOnEnterKey({
needsAuth: boolean;
needsHostKeyVerification: boolean;
hasBlockingOverlay: boolean;
isReconnectActive?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
@@ -147,6 +220,7 @@ export function shouldReconnectTerminalOnEnterKey({
&& !needsAuth
&& !needsHostKeyVerification
&& !hasBlockingOverlay
&& !isReconnectActive
&& !altKey
&& !ctrlKey
&& !metaKey
@@ -300,9 +374,10 @@ export function resolveTerminalRightInset({
* equal, so it can never render stale UI.
*/
function terminalViewCtxEqual(
prev: { ctx: TerminalViewContext },
next: { ctx: TerminalViewContext },
prev: { ctx: TerminalViewContext; isPaneMagnified?: boolean },
next: { ctx: TerminalViewContext; isPaneMagnified?: boolean },
): boolean {
if (prev.isPaneMagnified !== next.isPaneMagnified) return false;
const a = prev.ctx;
const b = next.ctx;
if (a === b) return true;
@@ -314,8 +389,8 @@ function terminalViewCtxEqual(
return true;
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -391,6 +466,14 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
isSearchOpen,
terminalBodyInset,
});
const terminalBottomInset = terminalBodyInset + (showDisconnectedTerminalNotice ? 28 : 0);
const disconnectedTerminalNoticeMessage = resolveTerminalDisconnectedNoticeMessage({
status,
error,
reconnectMessage: reconnectNoticeMessage,
disconnectedLabel: t('terminal.progress.disconnected'),
isReconnectActive,
});
// Optimistic override so the gutter paints immediately; host vault write can
// lag behind without making the toolbar feel sticky.
const [timestampOverride, setTimestampOverride] = useState<boolean | null>(null);
@@ -444,21 +527,23 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const showEnterReconnectHint = shouldReconnectTerminalOnEnterKey({
key: "Enter",
status,
hasRetryHandler: Boolean(handleRetry),
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
isReconnectActive,
});
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectTerminalOnEnterKey({
key: event.key,
status,
hasRetryHandler: Boolean(handleRetry),
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
isReconnectActive,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
@@ -478,8 +563,10 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
handleRetry,
hasBlockingReconnectOverlay,
isComposeBarOpen,
isReconnectActive,
needsHostKeyVerification,
status,
terminalReconnectAvailable,
]);
return (
<TerminalContextMenu
@@ -506,8 +593,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
onSplitVertical={onSplitVertical}
onSendYmodem={ymodemActionEnabled ? handleSendYmodem : undefined}
onReceiveYmodem={ymodemActionEnabled ? handleReceiveYmodem : undefined}
isReconnectable={status === "disconnected"}
onReconnect={handleRetry}
isReconnectable={status === "disconnected" && !isReconnectActive && terminalReconnectAvailable !== false}
onReconnect={!isReconnectActive && terminalReconnectAvailable !== false ? handleRetry : undefined}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
onRename={onRename}
@@ -716,13 +803,13 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
shouldEnableStatusBarReconnect(status)
shouldEnableStatusBarReconnect(status) && !isReconnectActive
? "opacity-60 hover:opacity-100"
: "opacity-30 cursor-not-allowed",
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleRetry}
disabled={!shouldEnableStatusBarReconnect(status)}
disabled={!shouldEnableStatusBarReconnect(status) || isReconnectActive}
aria-label={t("terminal.statusbar.reconnect.label")}
>
<RefreshCcw size={10} />
@@ -797,15 +884,37 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
aria-label={t("terminal.toolbar.focusMode")}
aria-label={t('terminal.toolbar.focusMode')}
>
<Maximize2 size={12} />
<PanelLeft size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.toolbar.focusMode")}</TooltipContent>
<TooltipContent side="bottom">{t('terminal.toolbar.focusMode')}</TooltipContent>
</Tooltip>
)}
{renderControls({ showClose: inWorkspace })}
{inWorkspace && !isFocusMode && onTogglePaneMagnification && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onTogglePaneMagnification}
aria-label={t(isPaneMagnified
? 'terminal.paneMagnification.restore'
: 'terminal.paneMagnification.magnify')}
>
{isPaneMagnified ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(isPaneMagnified
? 'terminal.paneMagnification.restore'
: 'terminal.paneMagnification.magnify')}
</TooltipContent>
</Tooltip>
)}
{renderControls({ showClose: inWorkspace, restorePaneLayout: isPaneMagnified })}
</div>
</>
);
@@ -960,7 +1069,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
top: terminalContentTop,
left: activeLineTimestampGutterWidth + terminalBodyInset,
right: terminalRightInset,
bottom: terminalBodyInset,
bottom: terminalBottomInset,
paddingLeft: 6,
backgroundColor: 'var(--terminal-ui-bg)',
}}
@@ -971,7 +1080,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
enabled={showLineTimestampGutter}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBodyInset}
bottom={terminalBottomInset}
sessionId={sessionId}
color={lineTimestampColor}
fontFamily={resolvedFontFamily}
@@ -980,6 +1089,19 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
width={lineTimestampGutterWidth}
onWidthChange={handleLineTimestampGutterWidthChange}
/>
{showDisconnectedTerminalNotice && (
<TerminalDisconnectedNotice
message={disconnectedTerminalNoticeMessage}
reconnectHint={showEnterReconnectHint ? t('terminal.progress.enterReconnectHint') : undefined}
bottom={terminalBodyInset}
left={terminalBodyInset}
right={terminalRightInset}
onPointerDown={(event) => focusTerminalFromDisconnectedNotice(
event,
() => termRef.current?.focus(),
)}
/>
)}
<TerminalSelectionAIOverlay
termRef={termRef}
containerRef={containerRef}
@@ -1148,7 +1270,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
progressLogs,
onCancelConnect: handleCancelConnect,
onCloseSession: handleCloseDisconnectedSession,
onRetry: handleRetry,
onRetry: terminalReconnectAvailable !== false ? handleRetry : undefined,
}}
/>
)}
@@ -528,6 +528,442 @@ test("a keyword written on a saturated scrollback is still colored", async () =>
term.dispose();
});
test("Mosh-style full-screen cursor-addressed repaint keeps highlights", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "row-1 ERROR\r\nrow-2 ERROR\r\nrow-3 ERROR\r\nrow-4 ERROR");
assert.deepEqual(
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
[RED, RED, RED, RED],
);
// Mosh redraws a numbered remote framebuffer with absolute cursor moves.
// Once the remote screen fills, an Enter can repaint rows above the local
// cursor without emitting newlines or leaving the primary buffer.
await write(
term,
"\x1b[1;1Hrow-2 ERROR\x1b[2;1Hrow-3 ERROR"
+ "\x1b[3;1Hrow-4 ERROR\x1b[4;1Hprompt ERROR",
);
assert.deepEqual(
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
[RED, RED, RED, RED],
);
// PTY chunking can split a cursor-addressing sequence at any byte.
await write(term, "\x1b[1;");
await write(
term,
"1Hsplit ERROR\x1b[2;1Hrow-4 ERROR"
+ "\x1b[3;1Hprompt ERROR\x1b[4;1Hdone ER",
);
await write(term, "ROR\x1b[1;1H");
assert.deepEqual(
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
[RED, RED, RED, RED],
);
highlighter.dispose();
term.dispose();
});
test("Mosh repaint highlights survive every transport split point", async () => {
const frame = "\x1b[1;1Hnew-1 ERROR\x1b[2;1Hnew-2 ERROR"
+ "\x1b[3;1Hnew-3 ERROR\x1b[4;1Hnew-4 ERROR\x1b[1;1H";
for (let split = 1; split < frame.length; split += 1) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
await write(term, frame.slice(0, split));
await write(term, frame.slice(split));
assert.deepEqual(
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
[RED, RED, RED, RED],
`split ${split}`,
);
highlighter.dispose();
term.dispose();
}
});
test("cursor-addressed repaint follows CRLF rows before restoring the cursor", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
await write(term, "\x1b[1;1Hnew-1 ERROR\r\nnew-2 ERROR\r\nnew-3 ERROR\x1b[1;1H");
assert.deepEqual(
Array.from({ length: 3 }, (_, y) => cellRgb(term, y, "ERROR")),
[RED, RED, RED],
);
highlighter.dispose();
term.dispose();
});
test("cursor-addressed repaint keeps pre-scroll rows highlighted", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
await write(
term,
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
);
const highlightedRows: number[] = [];
for (let y = 0; y < term.buffer.active.length; y += 1) {
if (cellRgb(term, y, "ERROR") === RED) highlightedRows.push(y);
}
assert.deepEqual(highlightedRows, [0]);
highlighter.dispose();
term.dispose();
});
test("pressured cursor-addressed repaint catches up from the pre-scroll viewport", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
noteTerminalOutputPressureData(term, "x".repeat(20_000));
await write(
term,
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
);
await highlighter.whenSettled();
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("row-sized Mosh repaint writes do not rescan the viewport for every row", async () => {
for (const rows of [40, 80]) {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
const internal = highlighter as unknown as {
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
};
const originalRecolorRange = internal.recolorRange.bind(highlighter);
let scannedRows = 0;
let refreshes = 0;
internal.recolorRange = (startY, endY, refresh, force) => {
scannedRows += Math.abs(endY - startY) + 1;
if (refresh) refreshes += 1;
originalRecolorRange(startY, endY, refresh, force);
};
for (let row = 1; row <= rows; row += 1) {
await write(term, `\x1b[${row};1Hnew-${row} ERROR`);
}
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
assert.ok(
scannedRows <= rows * 2,
`${rows} rows should stay linear, scanned ${scannedRows} rows`,
);
assert.ok(
refreshes <= rows + 1,
`${rows} rows should refresh at most once per typical write, got ${refreshes}`,
);
highlighter.dispose();
term.dispose();
}
});
test("cursor-restoring Mosh row writes keep repaint work linear", async () => {
for (const rows of [40, 80]) {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
const internal = highlighter as unknown as {
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
};
const originalRecolorRange = internal.recolorRange.bind(highlighter);
let scannedRows = 0;
let refreshes = 0;
internal.recolorRange = (startY, endY, refresh, force) => {
scannedRows += Math.abs(endY - startY) + 1;
if (refresh) refreshes += 1;
originalRecolorRange(startY, endY, refresh, force);
};
for (let row = 1; row <= rows; row += 1) {
await write(term, `\x1b[${row};1Hnew-${row} ERROR\x1b[1;1H`);
}
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
assert.ok(scannedRows <= rows * 2, `${rows} rows scanned ${scannedRows}`);
assert.ok(refreshes <= rows * 2, `${rows} rows refreshed ${refreshes} times`);
highlighter.dispose();
term.dispose();
}
});
test("CUP fragments survive enabling highlighting between writes", async () => {
for (const control of ["\x1b[2;1H", "\x9b2;1H"]) {
for (let split = 1; split < control.length; split += 1) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), false);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, control.slice(0, split));
highlighter.setRules(rule(), true);
await highlighter.whenSettled();
await write(term, `${control.slice(split)}target ERROR\x1b[1;1H`);
assert.equal(cellRgb(term, 1, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
highlighter.dispose();
term.dispose();
}
}
});
test("C1 CUP repaints an earlier viewport row", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x9b2;1Htarget ERROR\x9b5;1H");
assert.equal(cellRgb(term, 1, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("out-of-range cursor addresses repaint the clamped viewport row", async () => {
for (const control of ["\x1b[999;1H", "\x1b[999d", "\x9b999;1H", "\x9b999d"]) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5\x1b[2;1H");
await write(term, `${control}target ERROR\x1b[2;1H`);
assert.equal(cellRgb(term, 4, "ERROR"), RED, JSON.stringify(control));
highlighter.dispose();
term.dispose();
}
});
test("cursor-addressed repaint follows VPR and inserted-line row changes", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
await write(term, "\x1b[1;1Hfirst ERROR\x1b[2ethird ERROR\x1b[1;1H");
await write(term, "\x1b[2;1H\x1b[1Linserted ERROR\x1b[1;1H");
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("C1 cursor traversal recolors every visited row", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x9b1;1Hfirst ERROR\x9b2ethird ERROR\x9b1;1H");
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("single-byte C1 row moves recolor every visited row", async () => {
for (const control of ["\x84", "\x85", "\x8d"]) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, `\x9b2;1Hfirst ERROR${control}second ERROR\x9b2;1H`);
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], JSON.stringify(control));
highlighter.dispose();
term.dispose();
}
});
test("cursor-addressed auto-wrap scrolling keeps the pre-scroll row highlighted", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 12, rows: 4, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
await write(term, `\x1b[4;1HERROR ${"x".repeat(24)}\x1b[1;1H`);
assert.ok(term.buffer.active.baseY > 0, "fixture must scroll through auto-wrap");
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("origin-mode cursor addresses stay safe across writes and recover after disable", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x1b[2;4r\x1b[?");
await write(term, "6h");
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
assert.equal(cellRgb(term, 2, "ERROR"), RED);
await write(term, "\x1b[?6l");
const internal = highlighter as unknown as {
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
};
const originalRecolorRange = internal.recolorRange.bind(highlighter);
let scannedRows = 0;
internal.recolorRange = (startY, endY, refresh, force) => {
scannedRows += Math.abs(endY - startY) + 1;
originalRecolorRange(startY, endY, refresh, force);
};
await write(term, "\x1b[5;1Hnormal ERROR");
assert.equal(cellRgb(term, 4, "ERROR"), RED);
assert.ok(scannedRows <= 2, `origin-mode disable should restore the narrow path: ${scannedRows}`);
highlighter.dispose();
term.dispose();
});
test("origin mode is remembered while keyword highlighting is disabled", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), false);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x1b[2;4r\x1b[?6h");
highlighter.setRules(rule(), true);
await highlighter.whenSettled();
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
assert.equal(cellRgb(term, 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("multi-parameter origin mode is recognized at every transport split", async () => {
for (const control of ["\x1b[?6;25h", "\x1b[?25;6h"]) {
for (let split = 1; split < control.length; split += 1) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x1b[2;4r");
await write(term, control.slice(0, split));
await write(term, control.slice(split));
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
assert.equal(cellRgb(term, 2, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
await write(term, `${control.slice(0, -1)}l`);
const internal = highlighter as unknown as {
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
};
const originalRecolorRange = internal.recolorRange.bind(highlighter);
let scannedRows = 0;
internal.recolorRange = (startY, endY, refresh, force) => {
scannedRows += Math.abs(endY - startY) + 1;
originalRecolorRange(startY, endY, refresh, force);
};
await write(term, "\x1b[5;1Hnormal ERROR");
assert.ok(scannedRows <= 2, `${JSON.stringify(control)} disable split ${split}: ${scannedRows}`);
highlighter.dispose();
term.dispose();
}
}
});
test("saved origin mode is restored across every cursor-control split", async () => {
const controls = [
{ save: "\x1b7", restore: "\x1b8", disableHighlightDuringRestore: false },
{ save: "\x1b[s", restore: "\x1b[u", disableHighlightDuringRestore: true },
];
for (const entry of controls) {
for (let saveSplit = 1; saveSplit < entry.save.length; saveSplit += 1) {
for (let restoreSplit = 1; restoreSplit < entry.restore.length; restoreSplit += 1) {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x1b[2;4r\x1b[?6h");
await write(term, entry.save.slice(0, saveSplit));
await write(term, entry.save.slice(saveSplit));
await write(term, "\x1b[?6l");
if (entry.disableHighlightDuringRestore) highlighter.setRules(rule(), false);
await write(term, entry.restore.slice(0, restoreSplit));
await write(term, entry.restore.slice(restoreSplit));
if (entry.disableHighlightDuringRestore) {
highlighter.setRules(rule(), true);
await highlighter.whenSettled();
}
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
const fixture = `${JSON.stringify(entry)} save ${saveSplit} restore ${restoreSplit}`;
assert.equal(cellRgb(term, 2, "ERROR"), RED, fixture);
await write(term, "\x1b[?6l");
const internal = highlighter as unknown as {
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
};
const originalRecolorRange = internal.recolorRange.bind(highlighter);
let scannedRows = 0;
internal.recolorRange = (startY, endY, refresh, force) => {
scannedRows += Math.abs(endY - startY) + 1;
originalRecolorRange(startY, endY, refresh, force);
};
await write(term, "\x1b[5;1Hnormal ERROR");
assert.ok(scannedRows <= 2, `${fixture}: ${scannedRows}`);
highlighter.dispose();
term.dispose();
}
}
}
});
test("C1 cursor save and restore preserves origin mode", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x9b2;4r\x9b?6;25h\x9bs\x9b?6l\x9bu");
await write(term, "\x9b2;1Htarget ERROR\x9b1;1H");
assert.equal(cellRgb(term, 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("normal-buffer origin mode survives an alternate-screen round trip", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
await write(term, "\x1b[2;4r\x1b[?6h\x1b[?1049h");
await write(term, "\x1b[?6l");
await write(term, "\x1b[?1049l");
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
assert.equal(cellRgb(term, 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("scrolling during a rule change catch-up recolors newly visible rows", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 80 });
let bypass = true;
+219 -8
View File
@@ -45,6 +45,11 @@ type LogicalLine = {
cellAtStringOffset: Array<{ y: number; x: number }>;
};
type AbsoluteRepaintRange = {
rows: number[];
mayTraverseRows: boolean;
};
export type KeywordHighlighterOptions = {
shouldBypassHighlight?: () => boolean;
serializeAddon?: SerializeAddon;
@@ -180,6 +185,18 @@ const collectMatches = (
const yieldToRenderer = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
const trailingIncompleteCsi = (controls: string): string => {
const escapeCsi = controls.lastIndexOf("\x1b[");
const c1Csi = controls.lastIndexOf("\x9b");
const csiStart = Math.max(escapeCsi, c1Csi);
if (csiStart >= 0) {
const suffix = controls.slice(csiStart);
const body = suffix.startsWith("\x1b[") ? suffix.slice(2) : suffix.slice(1);
if (!/[\x40-\x7e]/.test(body)) return suffix.slice(-32);
}
return controls.endsWith("\x1b") ? "\x1b" : "";
};
/**
* Keyword highlighting mutates already-parsed cell foregrounds. Writes stay
* pristine, so ordinary Enter/output never rebuilds history, and serialize can
@@ -217,6 +234,12 @@ export class KeywordHighlighter implements IDisposable {
private lastViewportY = 0;
private lastBaseY = 0;
private hasOutput = false;
private absoluteControlTail = "";
private absoluteOriginControlTail = "";
private absoluteOriginMode: boolean | null = false;
private absoluteActiveBuffer: "normal" | "alternate" | null = "normal";
private absoluteNormalSavedOriginMode: boolean | null = false;
private absoluteAlternateSavedOriginMode: boolean | null = false;
get pendingPristineBytes(): number {
return 0;
@@ -357,6 +380,8 @@ export class KeywordHighlighter implements IDisposable {
private readonly write: XTerm["write"] = (data, callback) => {
if (this.disposed) return this.originalWrite(data, callback);
const originModeNeedsSafety = this.trackAbsoluteOriginMode(data);
const absoluteControls = this.collectAbsoluteControls(data);
const startedOnNormal = this.term.buffer.active.type === "normal";
if (!startedOnNormal && this.compiledPatterns.length === 0 && !this.hasPendingCatchUp()) {
return this.originalWrite(data, callback);
@@ -365,11 +390,15 @@ export class KeywordHighlighter implements IDisposable {
if (this.compiledPatterns.length === 0 && !this.hasPendingCatchUp() && startedOnNormal) {
return this.originalWrite(data, callback);
}
const startY = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
const startBaseY = this.term.buffer.active.baseY;
const startY = startBaseY + this.term.buffer.active.cursorY;
const absoluteRepaintRange = absoluteControls !== null
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
: null;
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
if (bypass) {
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
this.markCatchUp(startY);
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
this.scheduleCatchUp();
}
return this.originalWrite(data, () => {
@@ -423,16 +452,77 @@ export class KeywordHighlighter implements IDisposable {
} else {
const startRowMutated = startRowTextBefore !== undefined
&& active.getLine(writeMarker.line)?.translateToString(false) !== startRowTextBefore;
const fromY = skipStartRow && !startRowMutated
const ordinaryFromY = skipStartRow && !startRowMutated
? Math.min(endY, writeMarker.line + 1)
: writeMarker.line;
if (this.compiledPatterns.length === 0) {
if (this.hasStoredOriginalsInRange(fromY, endY)) {
this.markCatchUp(fromY);
this.scheduleCatchUp();
if (
absoluteRepaintRange !== null
&& !absoluteRepaintRange.mayTraverseRows
&& active.baseY === startBaseY
) {
// A normal Mosh framebuffer diff sends one absolute row update per
// write. Recolor the old cursor row and the addressed rows as
// separate ranges so N row-sized writes stay O(N), rather than
// rescanning the gaps between them for every write.
const repaintRanges = absoluteRepaintRange.rows.map((row) => ({
start: startBaseY + row,
end: startBaseY + row,
}));
const includesLine = (line: number) => repaintRanges.some((range) => (
line >= range.start && line <= range.end
));
if (!includesLine(ordinaryFromY)) {
repaintRanges.push({ start: ordinaryFromY, end: ordinaryFromY });
}
if (!includesLine(endY) && endY !== ordinaryFromY) {
repaintRanges.push({ start: endY, end: endY });
}
repaintRanges.sort((left, right) => left.start - right.start);
for (let index = repaintRanges.length - 1; index > 0; index -= 1) {
const previous = repaintRanges[index - 1];
const current = repaintRanges[index];
if (current.start > previous.end + 1) continue;
previous.end = Math.max(previous.end, current.end);
repaintRanges.splice(index, 1);
}
if (this.compiledPatterns.length === 0) {
const catchUpFrom = repaintRanges.reduce<number | null>((earliest, range) => (
this.hasStoredOriginalsInRange(range.start, range.end)
? Math.min(earliest ?? range.start, range.start)
: earliest
), null);
if (catchUpFrom !== null) {
this.markCatchUp(catchUpFrom);
this.scheduleCatchUp();
}
} else {
for (const range of repaintRanges) {
this.recolorRange(range.start, range.end, true, true);
}
}
} else {
this.recolorRange(fromY, endY, true, true);
// An absolute-positioned update that also traverses rows via
// CRLF/IND can scroll before restoring its final cursor. Cover the
// pre-write and post-write viewports for that uncommon case.
const fromY = absoluteRepaintRange === null
? ordinaryFromY
: Math.min(ordinaryFromY, startBaseY, active.baseY);
const toY = absoluteRepaintRange === null
? endY
: Math.max(
ordinaryFromY,
endY,
startBaseY + this.term.rows - 1,
active.baseY + this.term.rows - 1,
);
if (this.compiledPatterns.length === 0) {
if (this.hasStoredOriginalsInRange(fromY, toY)) {
this.markCatchUp(fromY);
this.scheduleCatchUp();
}
} else {
this.recolorRange(fromY, toY, true, true);
}
}
}
} else if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0)) {
@@ -444,10 +534,131 @@ export class KeywordHighlighter implements IDisposable {
});
};
private trackAbsoluteOriginMode(data: string | Uint8Array): boolean {
if (typeof data !== "string") {
this.absoluteOriginControlTail = "";
this.absoluteOriginMode = null;
this.absoluteActiveBuffer = null;
this.absoluteNormalSavedOriginMode = null;
this.absoluteAlternateSavedOriginMode = null;
return true;
}
const controls = this.absoluteOriginControlTail + data;
this.absoluteOriginControlTail = trailingIncompleteCsi(controls);
const saveOriginMode = () => {
if (this.absoluteActiveBuffer === "normal") {
this.absoluteNormalSavedOriginMode = this.absoluteOriginMode;
} else if (this.absoluteActiveBuffer === "alternate") {
this.absoluteAlternateSavedOriginMode = this.absoluteOriginMode;
} else {
this.absoluteNormalSavedOriginMode = null;
this.absoluteAlternateSavedOriginMode = null;
}
};
const restoreOriginMode = () => {
this.absoluteOriginMode = this.absoluteActiveBuffer === "normal"
? this.absoluteNormalSavedOriginMode
: this.absoluteActiveBuffer === "alternate"
? this.absoluteAlternateSavedOriginMode
: null;
};
const originModeControls = [
...controls.matchAll(
/\x1bc|\x1b[78]|(?:\x1b\[|\x9b)(?:!p|[\d;]*[su]|\?[\d;]*[hl])/g, // eslint-disable-line no-control-regex
),
];
let originModeNeedsSafety = this.absoluteOriginMode !== false;
for (const match of originModeControls) {
const control = match[0];
if (control === "\x1bc") {
this.absoluteOriginMode = false;
this.absoluteActiveBuffer = "normal";
this.absoluteNormalSavedOriginMode = false;
this.absoluteAlternateSavedOriginMode = false;
} else if (control === "\x1b[!p" || control === "\x9b!p") {
this.absoluteOriginMode = false;
} else if (control === "\x1b7" || control.endsWith("s")) {
saveOriginMode();
} else if (control === "\x1b8" || control.endsWith("u")) {
restoreOriginMode();
} else {
const parameters = /^(?:\x1b\[|\x9b)\?([\d;]*)[hl]$/.exec(control)?.[1] // eslint-disable-line no-control-regex
?.split(";")
.map((parameter) => Number.parseInt(parameter, 10))
?? [];
const enabled = control.endsWith("h");
for (const parameter of parameters) {
if (parameter === 6) {
this.absoluteOriginMode = enabled;
} else if (parameter === 1048) {
if (enabled) saveOriginMode();
else restoreOriginMode();
} else if (parameter === 1049) {
if (enabled) {
saveOriginMode();
this.absoluteActiveBuffer = "alternate";
} else {
this.absoluteActiveBuffer = "normal";
restoreOriginMode();
}
} else if (parameter === 47 || parameter === 1047) {
this.absoluteActiveBuffer = enabled ? "alternate" : "normal";
}
}
}
originModeNeedsSafety ||= this.absoluteOriginMode !== false;
}
return originModeNeedsSafety;
}
private collectAbsoluteControls(data: string | Uint8Array): string | null {
if (typeof data !== "string") {
this.absoluteControlTail = "";
return null;
}
const controls = this.absoluteControlTail + data;
this.absoluteControlTail = trailingIncompleteCsi(controls);
return controls;
}
private resolveAbsoluteRepaintRange(
controls: string,
originModeNeedsSafety: boolean,
): AbsoluteRepaintRange | null {
const rows = new Set<number>();
const noteRow = (raw: string | undefined) => {
const row = Math.min(
this.term.rows - 1,
Math.max(0, (Number.parseInt(raw || "1", 10) || 1) - 1),
);
rows.add(row);
};
// CUP/HVP and VPA address a viewport row directly. Mosh's framebuffer
// diff uses these controls to repaint rows above the current cursor once
// the remote screen fills, without emitting a newline or changing buffer.
const cup = /(?:\x1b\[|\x9b)(\d*)(?:;\d*)?[Hf]/g; // eslint-disable-line no-control-regex
const vpa = /(?:\x1b\[|\x9b)(\d*)d/g; // eslint-disable-line no-control-regex
for (const match of controls.matchAll(cup)) noteRow(match[1]);
for (const match of controls.matchAll(vpa)) noteRow(match[1]);
// These controls can visit rows that are not named by CUP/VPA. Keep the
// wider safety range only for writes that actually contain such movement.
const mayTraverseRows = originModeNeedsSafety
|| /[\n\v\f\x84\x85\x8d]|\x1b[DEM8]|(?:\x1b\[|\x9b)[\d;?]*[ABEFIJLMSTehlru]/.test(controls); // eslint-disable-line no-control-regex
return rows.size === 0
? null
: { rows: [...rows].sort((left, right) => left - right), mayTraverseRows };
}
private readonly reset: XTerm["reset"] = () => {
this.clearStoredOriginals();
this.cancelCatchUp();
this.hasOutput = false;
this.absoluteControlTail = "";
this.absoluteOriginControlTail = "";
this.absoluteOriginMode = false;
this.absoluteActiveBuffer = "normal";
this.absoluteNormalSavedOriginMode = false;
this.absoluteAlternateSavedOriginMode = false;
return this.originalReset();
};
@@ -76,7 +76,10 @@ test("manual reconnect resets connect automation before opening a new session",
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const manualBranchIndex = source.indexOf(
'if (mode === "manual") {\n clearAutoReconnect',
cancelBatchIndex,
);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
@@ -129,7 +132,7 @@ test("manual reconnect resets connect automation before opening a new session",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
"manual reconnect must clear connect-automation consumption before the new session boot continues",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
@@ -260,7 +263,8 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
const staleWakeDisposeGuardIndex = source.indexOf('reconnectWakeInvalidateModeRef.current === "dispose"', staleWakeGuardIndex);
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeDisposeGuardIndex);
const disconnectKeepModeIndex = source.indexOf('reconnectWakeInvalidateModeRef.current = "keep"', source.indexOf("const handleDisconnect"));
const missingTermReturnIndex = source.indexOf("if (!termRef.current) return;", reconnectIndex);
const missingTermGuardIndex = source.indexOf("if (!termRef.current) {", reconnectIndex);
const missingTermReturnIndex = source.indexOf("return;", missingTermGuardIndex);
assert.notEqual(wakePromiseRefIndex, -1);
assert.notEqual(wakeGuardRefIndex, -1);
@@ -281,9 +285,10 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
assert.notEqual(staleWakeDisposeGuardIndex, -1);
assert.notEqual(staleWakeDisposeIndex, -1);
assert.notEqual(disconnectKeepModeIndex, -1);
assert.notEqual(missingTermGuardIndex, -1);
assert.notEqual(missingTermReturnIndex, -1);
assert.ok(
hibernatedBranchIndex < missingTermReturnIndex && wakeCallIndex < missingTermReturnIndex,
hibernatedBranchIndex < missingTermGuardIndex && wakeCallIndex < missingTermGuardIndex,
"manual and auto reconnect must wake fully hibernated sessions before the terminal guard can stop the retry",
);
assert.ok(
@@ -1376,9 +1376,10 @@ test("startMosh defers startup commands until mosh-client is ready", async () =>
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
// Handshake output dismisses the overlay (connected) but must not send startup input yet.
// Ordinary handshake output must not dismiss the connection overlay before
// the interactive Mosh client is ready.
dataCb?.("ssh login banner\r\n", { moshHandshake: true });
assert.equal(ctx.hasConnectedRef.current, true);
assert.equal(ctx.hasConnectedRef.current, false);
assert.deepEqual(sent, []);
assert.ok(readyCb, "expected onMoshSessionReady subscription");
@@ -1389,6 +1390,7 @@ test("startMosh defers startup commands until mosh-client is ready", async () =>
readyCb?.({ sessionId: "mosh-session" });
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(ctx.hasConnectedRef.current, true);
assert.ok(sent.some((chunk) => chunk.includes("echo from-snippet")));
});
@@ -1137,10 +1137,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// handshake uses an ephemeral SSH PTY first; writing too early lands
// input on that PTY and is lost on the swap (issue #2199).
//
// Do not gate status=connected on ready: interactive password/OTP
// prompts during the SSH handshake need the overlay dismissed so the
// user can type into the terminal. Scripts wait on moshShellReady in
// Terminal.tsx instead.
// Keep the progress overlay until mosh-client is ready. The attachment
// path still dismisses it early for an interactive password/OTP prompt
// so the user can type into the terminal.
//
// Subscribe BEFORE startMoshSession: a fast passwordless handshake can
// emit ready before the await returns, and the event is not replayed.
@@ -1156,6 +1155,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const runMoshStartup = () => {
disposeMoshReady?.();
disposeMoshReady = undefined;
ctx.setIsConnectionAwaitingUserInput?.(false);
if (!ctx.hasConnectedRef.current) ctx.updateStatus("connected");
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, attachedSessionId, () => {
cancelPendingStartupCommand = undefined;
});
@@ -1226,6 +1227,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// hibernate detaches exit listeners without closing the session and
// would otherwise cancel a still-pending startup command.
onExit: cleanupMoshStartupWait,
deferConnectionDuringMoshHandshake: Boolean(ctx.terminalBackend.onMoshSessionReady),
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
@@ -256,6 +256,8 @@ export type TerminalSessionDataMeta = {
droppedOutputAlternateScreenAction?: 'enter' | 'leave';
/** True while Mosh is still on the ephemeral SSH handshake PTY. */
moshHandshake?: boolean;
/** The Mosh SSH bootstrap is blocked on input that Netcatty cannot answer automatically. */
moshHandshakeRequiresUserInput?: boolean;
terminalPerf?: NetcattyTerminalOutputPerfMeta;
/** Original host output units acknowledged even when an interceptor changes display length. */
pluginPipelineIngressBytes?: number;
@@ -2449,3 +2449,54 @@ test("attachSessionToTerminal marks connected on metadata-only or visible first
assert.deepEqual(statuses, ["connected"]);
assert.equal(ctx.hasConnectedRef.current, true);
});
test("Mosh handshake stays connecting until ready unless the backend needs terminal input", () => {
const { term } = createFakeTerm();
const statuses: string[] = [];
let onData: ((data: string, meta?: {
moshHandshake?: boolean;
moshHandshakeRequiresUserInput?: boolean;
}) => void) | null = null;
const ctx = {
...createContext(false),
sessionId: "mosh-session",
sessionRef: { current: null as string | null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null as (() => void) | null },
disposeExitRef: { current: null as (() => void) | null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
terminalBackend: {
onSessionData: (_id: string, cb: typeof onData) => {
onData = cb;
return () => {};
},
onSessionExit: () => () => {},
writeToSession: () => {},
resizeSession: () => {},
setSessionFlowPaused: () => {},
ackSessionFlow: () => {},
},
updateStatus: (status: string) => {
statuses.push(status);
if (status === "connected") ctx.hasConnectedRef.current = true;
},
setError: () => {},
onSessionExit: () => {},
};
attachSessionToTerminal(ctx as never, term, "mosh-session", {
deferConnectionDuringMoshHandshake: true,
});
onData?.("login banner\r\n", { moshHandshake: true });
assert.deepEqual(statuses, []);
onData?.("Password:", { moshHandshake: true });
assert.deepEqual(statuses, []);
onData?.("Verification code:", {
moshHandshake: true,
moshHandshakeRequiresUserInput: true,
});
assert.deepEqual(statuses, ["connected"]);
});
@@ -775,6 +775,7 @@ export const tryAttachSessionToTerminal = (
onConnected?: (meta?: TerminalSessionDataMeta) => void;
onExit?: (evt: TerminalSessionExitEvent) => void;
requireExplicitConnectionReady?: boolean;
deferConnectionDuringMoshHandshake?: boolean;
convertLfToCrlf?: boolean;
sudoAutofillPassword?: string;
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
@@ -837,6 +838,7 @@ export const attachSessionToTerminal = (
onConnected?: (meta?: TerminalSessionDataMeta) => void;
onExit?: (evt: TerminalSessionExitEvent) => void;
requireExplicitConnectionReady?: boolean;
deferConnectionDuringMoshHandshake?: boolean;
convertLfToCrlf?: boolean;
sudoAutofillPassword?: string;
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
@@ -885,6 +887,13 @@ export const attachSessionToTerminal = (
const markConnectedOnFirstOutput = (meta?: TerminalSessionDataMeta) => {
const pluginConnectionReady = meta?.pluginConnectionReady === true;
if (opts?.requireExplicitConnectionReady === true && !pluginConnectionReady) return;
if (
opts?.deferConnectionDuringMoshHandshake === true
&& meta?.moshHandshake === true
&& meta?.moshHandshakeRequiresUserInput !== true
) {
return;
}
if (ctx.hasConnectedRef.current && !pluginConnectionReady) return;
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
@@ -945,11 +954,9 @@ export const attachSessionToTerminal = (
data = sudoAutofill?.handleOutput(data) ?? data;
writeSessionData(ctx, term, data, ingressBytes, meta);
ctx.onTerminalOutput?.(data, meta);
// Mark connected on first visible output so the connection overlay
// dismisses and interactive Mosh handshake prompts (password/OTP)
// remain reachable. Startup commands / pending scripts are gated
// separately on netcatty:mosh:ready so they do not hit the handshake
// PTY (#2199).
// Ordinary transports connect on first visible output. Mosh defers that
// transition until ready unless its SSH bootstrap explicitly reports
// that it is blocked on input Netcatty could not answer automatically.
markConnectedOnFirstOutput(meta);
},
{ replayBacklog: true },
@@ -26,7 +26,10 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
assert.match(supportSource, /inert=\{isVisible \? undefined : true\}/);
assert.match(
supportSource,
/inert=\{isVisible && !isCoveredByMagnification \? undefined : true\}/,
);
assert.match(viewSource, /ctx\.hibernateHiddenTabs/);
assert.match(viewSource, /inert=\{ctx\.isTerminalLayerVisible \? undefined : true\}/);
});
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more