mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f97044199 | ||
|
|
d9d0be4df1 | ||
|
|
180470a03b | ||
|
|
a3380d26e8 | ||
|
|
5dbede1368 | ||
|
|
715f71f18c | ||
|
|
d809b6c3fa | ||
|
|
65d334ff8d | ||
|
|
a09a0cb4ec | ||
|
|
101b686dbd | ||
|
|
5f67c8b720 | ||
|
|
eb615b9da5 | ||
|
|
4f3ca3ec9d | ||
|
|
f556c9688f | ||
|
|
00d1c819cf | ||
|
|
8bae9ea584 | ||
|
|
5ad4eaa309 | ||
|
|
981807e6ca | ||
|
|
2f020e6513 | ||
|
|
4733cb77fb | ||
|
|
cab224e642 | ||
|
|
08044ee987 | ||
|
|
b27aef1cd8 | ||
|
|
340f53722c | ||
|
|
cbd25c3fef | ||
|
|
b21e2a7e4b | ||
|
|
132aef813a | ||
|
|
2ad03f5ad2 | ||
|
|
2967a5a189 | ||
|
|
d87a95725d | ||
|
|
1bbb0e1f65 | ||
|
|
f65a9ffc2d | ||
|
|
05e9447998 | ||
|
|
fbacff7983 | ||
|
|
1b188c8aad | ||
|
|
eaacd01055 | ||
|
|
2ab2321226 | ||
|
|
95e1a147d8 | ||
|
|
a1f5181e58 | ||
|
|
971f702c32 | ||
|
|
5dbdf0965a | ||
|
|
b24d6dd196 | ||
|
|
b9ba7349c1 | ||
|
|
a40c0616d7 | ||
|
|
71e5c280fd | ||
|
|
b92084c477 | ||
|
|
ae669daf19 | ||
|
|
d049b85df4 | ||
|
|
a151240017 | ||
|
|
b09ae31830 | ||
|
|
0e74c310d2 | ||
|
|
359c2b7690 | ||
|
|
0fd7bed991 | ||
|
|
f23caa5026 | ||
|
|
dd4cd0a587 | ||
|
|
54e32dbcae | ||
|
|
8fc811d3e0 | ||
|
|
1a12dfa173 | ||
|
|
97d167eef7 | ||
|
|
b852f267f8 | ||
|
|
af1b624747 | ||
|
|
ff5c062bf1 | ||
|
|
539594845f | ||
|
|
3e2fa04b08 | ||
|
|
5718566ddb | ||
|
|
e345e59219 | ||
|
|
88b0a908a9 | ||
|
|
06e8be3df9 | ||
|
|
e8f51ea63e | ||
|
|
15c8b44818 | ||
|
|
8df32d5663 | ||
|
|
00ad6198b8 | ||
|
|
0ac9ca4245 | ||
|
|
fff99e46f8 | ||
|
|
73936c7792 | ||
|
|
8be8411a1f | ||
|
|
1d633431c1 | ||
|
|
21a0903b32 | ||
|
|
918d0d81f0 | ||
|
|
dd45af8fe3 | ||
|
|
e171ee1fe1 | ||
|
|
ada3d13ca3 | ||
|
|
6c5251c936 | ||
|
|
eee2191c8a | ||
|
|
2e51ff8b54 | ||
|
|
4525ea1c03 | ||
|
|
066e658afd | ||
|
|
88387b9c2b | ||
|
|
95ee17f3cc | ||
|
|
4ff20453f2 | ||
|
|
155401943b | ||
|
|
e543266b02 | ||
|
|
13b1bf506e | ||
|
|
f6050e1e44 | ||
|
|
e7b58a8831 | ||
|
|
992a303818 | ||
|
|
a3ab956999 | ||
|
|
21880ef1ea | ||
|
|
308df815d8 | ||
|
|
c138e1fc74 | ||
|
|
600544fd7f | ||
|
|
7e379ea5f5 | ||
|
|
ac3eb88956 | ||
|
|
140d78ae9e | ||
|
|
b7de0bb441 | ||
|
|
b11eaa172f | ||
|
|
809cbfdf79 | ||
|
|
d5198f45ca | ||
|
|
a233d3fb91 | ||
|
|
401d9d7a91 | ||
|
|
82e2f7287d | ||
|
|
dd30ff0e20 | ||
|
|
bb655caaa1 | ||
|
|
00f91c7f07 | ||
|
|
f5c5bcd17c | ||
|
|
dc6d1d9b60 | ||
|
|
3000837f4e | ||
|
|
4709ff2939 | ||
|
|
6583f422cd | ||
|
|
e6ed93a4ac | ||
|
|
e4c1ab2a8f | ||
|
|
a9e27f9688 | ||
|
|
29c90f1974 | ||
|
|
c4354d9451 | ||
|
|
0bf7e187e6 | ||
|
|
08b9027ef1 | ||
|
|
76fd52e85a | ||
|
|
9dc4336c4a | ||
|
|
bb71d28846 | ||
|
|
61b9cf2584 | ||
|
|
acfc581719 | ||
|
|
486a8d20e1 | ||
|
|
7a268b7726 | ||
|
|
54fbcc5531 | ||
|
|
6bc131cc69 | ||
|
|
913789992d | ||
|
|
ff25cb1a71 | ||
|
|
497afbbe82 | ||
|
|
9b0c07e614 | ||
|
|
3bf2242ac4 | ||
|
|
8988ebe903 | ||
|
|
53dd8af4c0 | ||
|
|
2c4657016a | ||
|
|
2568109538 | ||
|
|
49fc6e3fba | ||
|
|
c32646b570 | ||
|
|
7c45ce8c19 | ||
|
|
fd3b648a87 | ||
|
|
75e46b5584 | ||
|
|
8191f0b67b | ||
|
|
f034754455 | ||
|
|
623aa97e72 | ||
|
|
2897a18f1e | ||
|
|
cf01441dae | ||
|
|
95dd8b62c4 | ||
|
|
ab3eae409a | ||
|
|
79aafcdb1a | ||
|
|
d2a80e04d4 | ||
|
|
02addeaea9 | ||
|
|
e6b5b76efb | ||
|
|
0184d3c34f | ||
|
|
32afe5bf4b | ||
|
|
1f337d28ed | ||
|
|
3632b42e8e | ||
|
|
ea2c2ad301 | ||
|
|
988783c286 | ||
|
|
33de6ee388 | ||
|
|
d98cd37be6 | ||
|
|
ca344ad0a8 | ||
|
|
e7dcd22a7a | ||
|
|
d752897aa1 | ||
|
|
be6909c5ea | ||
|
|
f3e67fc0aa | ||
|
|
e1f915a767 | ||
|
|
e70f60f270 | ||
|
|
5db74fbb2d | ||
|
|
29c9610315 | ||
|
|
afc02116a8 | ||
|
|
66310a1172 | ||
|
|
399657247d | ||
|
|
ad1c5c9a3a | ||
|
|
d5bd69ff90 | ||
|
|
60ea41523a | ||
|
|
78d50b78da | ||
|
|
7a43157ae1 | ||
|
|
31e75e114e | ||
|
|
a6180c7db1 | ||
|
|
78d6662ce8 | ||
|
|
9f72eb6469 | ||
|
|
e882ec01f7 | ||
|
|
91c611c9f3 | ||
|
|
6f470b3142 | ||
|
|
d8d14253d6 | ||
|
|
14f4b31097 | ||
|
|
5245ebbaa0 | ||
|
|
e9f2a7c883 | ||
|
|
d7d711e1e3 | ||
|
|
a8e5b4992c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -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) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -534,6 +534,9 @@ export const enAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated through Netcatty execution.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.responseIdleTimeout': 'Built-in AI Response Wait',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancel a built-in AI request after this many seconds without a new response. This setting does not control total response time or command execution.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. External agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -534,6 +534,9 @@ export const esAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
|
||||
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
|
||||
'ai.safety.commandTimeout.unit': 'seg',
|
||||
'ai.safety.responseIdleTimeout': 'Espera de respuesta de la IA integrada',
|
||||
'ai.safety.responseIdleTimeout.description': 'Cancela una solicitud de la IA integrada tras este número de segundos sin una nueva respuesta. No controla la duración total ni la ejecución de comandos.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'seg',
|
||||
'ai.safety.maxIterations': 'Máximo de iteraciones',
|
||||
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
|
||||
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
|
||||
@@ -653,4 +656,4 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
};
|
||||
@@ -486,6 +486,9 @@ export const ruAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': 'Тайм-аут команды',
|
||||
'ai.safety.commandTimeout.description': 'Максимальное число секунд, которое команда может выполняться до принудительного завершения. Применяется как к встроенным, так и к внешним агентам.',
|
||||
'ai.safety.commandTimeout.unit': 'с',
|
||||
'ai.safety.responseIdleTimeout': 'Ожидание ответа встроенного ИИ',
|
||||
'ai.safety.responseIdleTimeout.description': 'Отменяет запрос встроенного ИИ после указанного числа секунд без нового ответа. Не управляет общей длительностью ответа и выполнением команд.',
|
||||
'ai.safety.responseIdleTimeout.unit': 'с',
|
||||
'ai.safety.maxIterations': 'Макс. число итераций',
|
||||
'ai.safety.maxIterations.description': 'Максимальное число циклов использования инструментов AI, чтобы предотвратить бесконтрольное выполнение. У внешних агентов могут быть собственные внутренние лимиты итераций, имеющие приоритет.',
|
||||
'ai.safety.blocklist': 'Чёрный список команд',
|
||||
|
||||
@@ -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': 'Выбрать слово',
|
||||
|
||||
@@ -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': 'Показать журналы',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -534,6 +534,9 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '通过 Netcatty 执行命令时允许运行的最长秒数,超时将被终止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.responseIdleTimeout': '内置 AI 响应等待时间',
|
||||
'ai.safety.responseIdleTimeout.description': '内置 AI 请求连续这么多秒没有收到新响应时会被取消。这个设置不控制整段响应总时长,也不影响命令超时。',
|
||||
'ai.safety.responseIdleTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。外部 Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
|
||||
@@ -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': '选择单词',
|
||||
|
||||
@@ -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': '新文件',
|
||||
|
||||
@@ -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,13 +526,16 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '權限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和審批流程。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 透過 Netcatty 存取終端工作階段的方式。觀察者模式會阻止經由 Netcatty 的寫操作;外部 Agent CLI 可能仍有自己的本機工具和核准流程。',
|
||||
'ai.safety.permissionMode.observer': '觀察者 - 只讀,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '確認 - 操作前詢問',
|
||||
'ai.safety.permissionMode.auto': '自動 - 自由執行',
|
||||
'ai.safety.commandTimeout': '指令超時',
|
||||
'ai.safety.commandTimeout.description': '透過 Netcatty 執行指令時允許執行的最長秒數,超時將被終止。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.responseIdleTimeout': '內建 AI 回應等待時間',
|
||||
'ai.safety.responseIdleTimeout.description': '內建 AI 請求連續這麼多秒沒有收到新回應時會被取消。此設定不控制整段回應總時長,也不影響指令超時。',
|
||||
'ai.safety.responseIdleTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次數',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控執行的最大工具呼叫迴圈次數。外部 Agent 可能有自己的內部迭代限制,以其為準。',
|
||||
'ai.safety.blocklist': '指令黑名單',
|
||||
@@ -543,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': '指令模式(可選)',
|
||||
@@ -578,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': '清除篩選',
|
||||
@@ -589,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': '快捷控制',
|
||||
@@ -651,5 +654,5 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.overwrite': '覆蓋',
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置為預設',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
};
|
||||
@@ -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': '定位路徑到終端',
|
||||
@@ -944,9 +1032,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goHome': '返回主目錄',
|
||||
'sftp.folderName': '資料夾名稱',
|
||||
'sftp.folderName.placeholder': '輸入資料夾名稱',
|
||||
'sftp.fileName': '檔名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔名稱',
|
||||
'sftp.prompt.newFolderName': '新建資料夾名稱?',
|
||||
'sftp.fileName': '檔案名稱',
|
||||
'sftp.fileName.placeholder': '輸入檔案名稱',
|
||||
'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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '儲存密碼',
|
||||
|
||||
@@ -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': '新檔案',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type SftpFilterFocusListener = () => void;
|
||||
|
||||
const listenersByPaneId = new Map<string, Set<SftpFilterFocusListener>>();
|
||||
|
||||
export const sftpFilterFocusStore = {
|
||||
request(paneId: string): void {
|
||||
listenersByPaneId.get(paneId)?.forEach((listener) => listener());
|
||||
},
|
||||
|
||||
subscribe(paneId: string, listener: SftpFilterFocusListener): () => void {
|
||||
const listeners = listenersByPaneId.get(paneId) ?? new Set<SftpFilterFocusListener>();
|
||||
listeners.add(listener);
|
||||
listenersByPaneId.set(paneId, listeners);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
listenersByPaneId.delete(paneId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -103,6 +103,7 @@ export interface SendToCattyContext {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
@@ -29,7 +30,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -74,6 +77,12 @@ export function useAISettingsState() {
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
|
||||
normalizeResponseIdleTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
@@ -175,6 +184,12 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setResponseIdleTimeout = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
|
||||
setResponseIdleTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
@@ -249,6 +264,13 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) break;
|
||||
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) break;
|
||||
@@ -315,6 +337,8 @@ export function useAISettingsState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
@@ -346,6 +370,8 @@ export function useAISettingsState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import { resolveInheritedAIActiveSessionId } from '../../domain/aiWorkspaceScopeInherit.ts';
|
||||
import { resolveDisplayedPanelView } from '../../components/ai/aiPanelViewState.ts';
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
|
||||
const SCOPE_KEY = 'workspace:workspace-old';
|
||||
const RESTORED_SESSION: AISession = {
|
||||
id: 'chat-old',
|
||||
title: 'Restored chat',
|
||||
agentId: 'catty',
|
||||
scope: {
|
||||
type: 'terminal',
|
||||
targetId: 'terminal-a',
|
||||
hostIds: ['host-a'],
|
||||
},
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'old question', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'old answer', timestamp: 2 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
function NewChatHarness() {
|
||||
const ai = useAIState();
|
||||
const state = useAISessionsStore();
|
||||
const visibleSessionIds = new Set(state.sessions.map((session) => session.id));
|
||||
const inheritedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'workspace-old',
|
||||
activeSessionIdMap: state.activeSessionIdMap,
|
||||
memberTerminalIds: ['terminal-a', 'terminal-b'],
|
||||
preferredTerminalId: 'terminal-a',
|
||||
visibleSessionIds,
|
||||
});
|
||||
const view = resolveDisplayedPanelView(
|
||||
state.panelViewByScope[SCOPE_KEY],
|
||||
state.draftsByScope[SCOPE_KEY] != null,
|
||||
state.sessions as AISession[],
|
||||
inheritedSessionId,
|
||||
'workspace',
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-view={view.mode}
|
||||
data-session={view.mode === 'session' ? view.sessionId : ''}
|
||||
onClick={() => {
|
||||
ai.clearDraftForScope(SCOPE_KEY);
|
||||
ai.updateDraft(SCOPE_KEY, 'catty', () => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
ai.showDraftView(SCOPE_KEY);
|
||||
}}
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('restored workspace enters a blank draft when New Chat is clicked', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: dom.window.document,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: dom.window.localStorage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||
configurable: true,
|
||||
value: dom.window.CustomEvent,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Event', {
|
||||
configurable: true,
|
||||
value: dom.window.Event,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([RESTORED_SESSION]));
|
||||
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify({
|
||||
'terminal:terminal-a': RESTORED_SESSION.id,
|
||||
[SCOPE_KEY]: RESTORED_SESSION.id,
|
||||
}));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<NewChatHarness />);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
assert.ok(button);
|
||||
assert.equal(button.dataset.view, 'session');
|
||||
assert.equal(button.dataset.session, RESTORED_SESSION.id);
|
||||
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
assert.equal(button.dataset.view, 'draft');
|
||||
assert.equal(button.dataset.session, '');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
@@ -38,7 +39,9 @@ import type {
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
@@ -145,6 +148,12 @@ export function useAIState() {
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
|
||||
normalizeResponseIdleTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
)
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
@@ -429,6 +438,12 @@ export function useAIState() {
|
||||
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setResponseIdleTimeout = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
|
||||
setResponseIdleTimeoutRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
@@ -509,6 +524,16 @@ export function useAIState() {
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
||||
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_RESPONSE_IDLE_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(timeout));
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) {
|
||||
@@ -926,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]);
|
||||
@@ -1238,6 +1254,8 @@ export function useAIState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
@@ -1298,6 +1316,8 @@ export function useAIState() {
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, "600");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
@@ -252,6 +253,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
defaultAgentId: "codex",
|
||||
commandBlocklist: ["rm -rf"],
|
||||
commandTimeout: 120,
|
||||
responseIdleTimeout: 600,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
@@ -269,6 +271,14 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("AI response wait time is syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
@@ -282,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");
|
||||
@@ -447,6 +481,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
defaultAgentId: "claude",
|
||||
commandBlocklist: ["shutdown"],
|
||||
commandTimeout: 30,
|
||||
responseIdleTimeout: 900,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
@@ -467,6 +502,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT), "900");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
@@ -1287,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,
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
@@ -107,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';
|
||||
@@ -251,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',
|
||||
@@ -274,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,
|
||||
@@ -311,6 +318,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
@@ -430,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);
|
||||
@@ -558,6 +572,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
|
||||
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
|
||||
const responseIdleTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
|
||||
if (responseIdleTimeout != null && Number.isFinite(responseIdleTimeout)) {
|
||||
ai.responseIdleTimeout = responseIdleTimeout;
|
||||
}
|
||||
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
@@ -649,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);
|
||||
@@ -797,6 +822,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
|
||||
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.responseIdleTimeout != null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, ai.responseIdleTimeout);
|
||||
}
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
||||
@@ -850,6 +878,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.responseIdleTimeout != null) touched.push(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT);
|
||||
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (ai.agentThinkingMap != null) touched.push(STORAGE_KEY_AI_AGENT_THINKING_MAP);
|
||||
|
||||
@@ -279,6 +279,18 @@ test('AI side panel re-renders when command timeout changes', () => {
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when response wait time changes', () => {
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
responseIdleTimeout: 120,
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
props,
|
||||
{ ...props, responseIdleTimeout: 600 },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when only a sibling scope session object changes', () => {
|
||||
const own = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
||||
const siblingA = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
||||
|
||||
@@ -282,6 +282,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
@@ -1352,6 +1353,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
globalPermissionMode: sendPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
@@ -1374,7 +1376,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, responseIdleTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
|
||||
@@ -1417,6 +1419,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope({
|
||||
@@ -1441,6 +1444,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
buildExecutorContextForScope,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
responseIdleTimeout,
|
||||
currentAgentId,
|
||||
effectiveActiveModelId,
|
||||
effectiveActiveProvider,
|
||||
@@ -1763,6 +1767,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
'commandTimeout',
|
||||
'responseIdleTimeout',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface AIChatSidePanelProps {
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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 destination-only content and cannot be undone/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */}');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
@@ -220,6 +222,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
responseIdleTimeout={aiState.responseIdleTimeout}
|
||||
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
@@ -312,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();
|
||||
@@ -336,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) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+81
-17
@@ -137,6 +137,7 @@ import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrit
|
||||
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
|
||||
import { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection";
|
||||
import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride";
|
||||
import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalOutputHistory";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
@@ -249,6 +250,7 @@ import {
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
shouldHideConnectingDialogForConnectionReuse,
|
||||
shouldShowTerminalDisconnectedNotice,
|
||||
shouldShowTerminalConnectionDialog,
|
||||
type TerminalProps,
|
||||
} from "./terminal/terminalHelpers";
|
||||
@@ -273,6 +275,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
inWorkspace,
|
||||
isResizing,
|
||||
isFocusMode,
|
||||
isPaneMagnified,
|
||||
isFocused,
|
||||
isFocusedPane,
|
||||
fontFamilyId,
|
||||
@@ -311,6 +314,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onExpandToFocus,
|
||||
onTogglePaneMagnification,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onSplitHorizontal,
|
||||
@@ -539,6 +543,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);
|
||||
@@ -553,6 +558,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const manualReconnectRequestRef = useRef<() => void>(() => {});
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
@@ -625,6 +631,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const readableCommandData = commandLogRewriterRef.current.append(data);
|
||||
// The alternate-screen preview reads the raw display stream (before the
|
||||
// replay sanitizer, which drops alternate-screen output on purpose) but
|
||||
// after the command rewriter, so masked commands stay masked (#2516).
|
||||
terminalOutputHistoryRef.current.append(readableCommandData);
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData);
|
||||
if (!replaySafeData) return;
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
@@ -880,6 +890,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 +1359,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 +1368,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 +1421,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 +3427,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 +3467,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
startReconnectRef.current?.(mode);
|
||||
return;
|
||||
}
|
||||
updateStatus("disconnected");
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}).catch(() => {
|
||||
if (reconnectWakeTokenRef.current !== wakeToken) {
|
||||
reconnectWakeInFlightRef.current = false;
|
||||
@@ -3447,11 +3478,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 +3510,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 +3526,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 +3557,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
if (mode === "manual" && retryTokenStillCurrent()) {
|
||||
restoreDisconnectedAfterReconnectFailure();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!retryTokenStillCurrent()) {
|
||||
@@ -3530,6 +3569,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 +3597,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 +3675,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 +3841,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 +3866,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 +3927,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
onToggleComposeBar,
|
||||
onTogglePaneMagnification,
|
||||
setIsComposeBarOpen,
|
||||
handleUpdateHostFromTerminal,
|
||||
sessionId,
|
||||
@@ -3873,6 +3935,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippets,
|
||||
status,
|
||||
terminalEncoding,
|
||||
t,
|
||||
recorder,
|
||||
workspaceId,
|
||||
]);
|
||||
@@ -3935,6 +3998,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLineBufferRef,
|
||||
telnetLocalEchoRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
terminalOutputHistory: terminalOutputHistoryRef.current,
|
||||
onCwdChange: (cwd: string) => {
|
||||
pluginAwareOnRuntimeCwdChange(cwd, { source: 'osc7' });
|
||||
},
|
||||
@@ -4212,11 +4276,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
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 +4289,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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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: "",
|
||||
syncedMarkdown: "",
|
||||
latestSourceMarkdown: "",
|
||||
syncedSourceMarkdown: "",
|
||||
};
|
||||
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "# Remote",
|
||||
}), false);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
latestSourceMarkdown: "",
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@use "katex/src/styles/katex.scss" with (
|
||||
$font-folder: "katex/dist/fonts",
|
||||
$use-woff: false,
|
||||
$use-ttf: false,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -153,6 +153,8 @@ interface SettingsAITabProps {
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
responseIdleTimeout: number;
|
||||
setResponseIdleTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
@@ -188,6 +190,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
@@ -1372,6 +1376,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCommandBlocklist={setCommandBlocklist}
|
||||
commandTimeout={commandTimeout}
|
||||
setCommandTimeout={setCommandTimeout}
|
||||
responseIdleTimeout={responseIdleTimeout}
|
||||
setResponseIdleTimeout={setResponseIdleTimeout}
|
||||
maxIterations={maxIterations}
|
||||
setMaxIterations={setMaxIterations}
|
||||
/>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decryptField } from "../../../../infrastructure/persistence/secureField
|
||||
import type { CursorAuthMode } from "../../../../infrastructure/ai/types";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { isCursorRuntimeInstalled, type AgentPathInfo } from "./types";
|
||||
|
||||
export const CursorSdkCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
@@ -54,7 +54,7 @@ export const CursorSdkCard: React.FC<{
|
||||
};
|
||||
}, [encryptedApiKey]);
|
||||
|
||||
const installed = Boolean(pathInfo?.installed || pathInfo?.sdkInstalled || pathInfo?.cliBinPath);
|
||||
const installed = isCursorRuntimeInstalled(pathInfo);
|
||||
const hasStoredApiKey = Boolean(encryptedApiKey);
|
||||
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY" || (
|
||||
pathInfo?.apiKeyOk && !hasStoredApiKey && pathInfo?.authSource !== "settings"
|
||||
@@ -71,7 +71,7 @@ export const CursorSdkCard: React.FC<{
|
||||
const isCliMode = authMode === "cli-login";
|
||||
const available = isCliMode
|
||||
? hasCliLogin
|
||||
: (hasAnyApiKey && Boolean(pathInfo?.sdkInstalled ?? pathInfo?.installed));
|
||||
: (hasAnyApiKey && Boolean(pathInfo?.sdkInstalled ?? true));
|
||||
const canSave = isApiKeyMode && !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
|
||||
|
||||
const installStatus = isResolvingPath
|
||||
@@ -161,7 +161,7 @@ export const CursorSdkCard: React.FC<{
|
||||
<p className="text-xs text-amber-500">
|
||||
{isCliMode
|
||||
? t("ai.cursor.cliLoginHint")
|
||||
: installed
|
||||
: (Boolean(pathInfo?.sdkInstalled) || installed)
|
||||
? t("ai.cursor.notFoundHint")
|
||||
: t("ai.cursor.notInstalledHint")}
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
|
||||
import { DEFAULT_COMMAND_BLOCKLIST, MAX_COMMAND_TIMEOUT_SECONDS } from "../../../../infrastructure/ai/types";
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
MAX_COMMAND_TIMEOUT_SECONDS,
|
||||
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Select, SettingCard, SettingRow, SettingsAnchor, SettingsSection } from "../../settings-ui";
|
||||
@@ -13,6 +17,8 @@ export const SafetySettings: React.FC<{
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
responseIdleTimeout: number;
|
||||
setResponseIdleTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
}> = ({
|
||||
@@ -22,6 +28,8 @@ export const SafetySettings: React.FC<{
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
responseIdleTimeout,
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
}) => {
|
||||
@@ -84,6 +92,28 @@ export const SafetySettings: React.FC<{
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="ai-safety-response-idle-timeout"
|
||||
label={t('ai.safety.responseIdleTimeout')}
|
||||
description={t('ai.safety.responseIdleTimeout.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t('ai.safety.responseIdleTimeout')}
|
||||
value={responseIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) setResponseIdleTimeout(val);
|
||||
}}
|
||||
min={1}
|
||||
max={MAX_RESPONSE_IDLE_TIMEOUT_SECONDS}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.safety.responseIdleTimeout.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="ai-safety-command-timeout"
|
||||
label={t('ai.safety.commandTimeout')}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isCursorAvailableForMode, isCursorRuntimeInstalled } from "./types";
|
||||
|
||||
test("isCursorRuntimeInstalled ignores bundled SDK flags", () => {
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: true,
|
||||
sdkInstalled: true,
|
||||
}), false);
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: false,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
cliBinPath: null,
|
||||
cliLoginOk: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("isCursorRuntimeInstalled is true for Agent CLI path or CLI login", () => {
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "/usr/local/bin/cursor-agent",
|
||||
version: "Cursor Agent CLI",
|
||||
available: false,
|
||||
sdkInstalled: true,
|
||||
cliBinPath: "/usr/local/bin/cursor-agent",
|
||||
cliLoginOk: false,
|
||||
}), true);
|
||||
assert.equal(isCursorRuntimeInstalled({
|
||||
path: "cursor",
|
||||
version: "Cursor Agent CLI",
|
||||
available: true,
|
||||
sdkInstalled: true,
|
||||
cliLoginOk: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("isCursorAvailableForMode still allows API-key mode from bundled SDK", () => {
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
apiKeyOk: true,
|
||||
}, "api-key"), true);
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: true,
|
||||
installed: false,
|
||||
apiKeyOk: true,
|
||||
}, "api-key"), true);
|
||||
assert.equal(isCursorAvailableForMode({
|
||||
path: "cursor",
|
||||
version: "Cursor SDK",
|
||||
available: false,
|
||||
installed: false,
|
||||
sdkInstalled: true,
|
||||
cliLoginOk: false,
|
||||
}, "cli-login"), false);
|
||||
});
|
||||
@@ -58,6 +58,7 @@ export interface AgentPathInfo {
|
||||
binPath?: string | null;
|
||||
version: string | null;
|
||||
available: boolean;
|
||||
/** True when the user's Cursor Agent CLI is on PATH or logged in. */
|
||||
installed?: boolean;
|
||||
authenticated?: boolean;
|
||||
authSource?: string | null;
|
||||
@@ -71,6 +72,11 @@ export interface AgentPathInfo {
|
||||
sdkInstalled?: boolean;
|
||||
}
|
||||
|
||||
/** User-environment Cursor Agent CLI, not Netcatty's bundled @cursor/sdk. */
|
||||
export function isCursorRuntimeInstalled(pathInfo: AgentPathInfo | null | undefined): boolean {
|
||||
return Boolean(pathInfo?.cliBinPath || pathInfo?.cliLoginOk);
|
||||
}
|
||||
|
||||
/** Mode-aware Cursor availability for Settings enablement. */
|
||||
export function isCursorAvailableForMode(
|
||||
pathInfo: AgentPathInfo | null | undefined,
|
||||
@@ -87,9 +93,11 @@ export function isCursorAvailableForMode(
|
||||
|| pathInfo.authSource === "settings"
|
||||
|| pathInfo.authSource === "CURSOR_API_KEY",
|
||||
);
|
||||
// Missing sdkInstalled means the probe has not filled it yet. API-key mode
|
||||
// uses Netcatty's bundled SDK and must not wait for Cursor.app.
|
||||
const sdkOk = pathInfo.sdkInstalled !== undefined
|
||||
? Boolean(pathInfo.sdkInstalled)
|
||||
: Boolean(pathInfo.installed);
|
||||
: true;
|
||||
return hasKey && sdkOk;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user