Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eccc1f52e7 | ||
|
|
806fd93638 | ||
|
|
1054c2a661 | ||
|
|
cca4ba029a | ||
|
|
24f7e7c52a | ||
|
|
c0342db1e8 | ||
|
|
23fa566fa9 | ||
|
|
e04f4f0ac1 |
No files matched your search
@@ -33,6 +33,9 @@ release/
|
||||
# stray local test artifacts
|
||||
.min-test.bin
|
||||
.min2-test.bin
|
||||
.exact-test.bin
|
||||
tests/.zm-src-*/
|
||||
tests/.zm-out-*/
|
||||
|
||||
# release notes drafts (consumed by scripts/release.cjs + sync-changelog.cjs)
|
||||
RELEASE_NOTES*.md
|
||||
|
||||
@@ -6,7 +6,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
|
||||
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
|
||||
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
|
||||
- 测试:`npm test`(先 `node tests/build-bundles.cjs` 重建 esbuild bundle,再依次跑可离线运行的 7 个测试;真实服务器测试需 JD_* 凭据,不在此列)
|
||||
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
|
||||
@@ -23,14 +24,17 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
|
||||
## 发布新版本
|
||||
|
||||
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
|
||||
2. `npm run dist` 构建
|
||||
3. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.11 --skip-github`
|
||||
- **只发国内**(2026-09-15 起的产品决定):Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)
|
||||
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore;可选 `.zh-TW/.en/.ja` 译文,缺译文回退简体)
|
||||
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
|
||||
3. `npm run dist` 构建
|
||||
4. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.14 --skip-github`
|
||||
- **只发国内**(2026-09-15 起的产品决定):Gitea release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
|
||||
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
|
||||
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
|
||||
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
|
||||
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
5. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
|
||||
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
|
||||
|
||||
## 更新机制
|
||||
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# OpenTerminal Changelog
|
||||
|
||||
## v1.0.14 - 2026-09-20
|
||||
|
||||
- Fixed silent corruption of uploaded files **larger than ~250KB** (size looked correct but the content had scrambled bytes)
|
||||
- Fixed a remote file-handle leak on every download and a leftover SFTP channel after every transfer; a malformed server response no longer crashes the whole app
|
||||
- Uploading over an existing file now asks for confirmation; partially failed multi-file deletes are now reported
|
||||
- Permission editor fixes: files with unknown mode no longer get a stray `chmod 000`; an empty GID is no longer replaced by the new UID; setuid / setgid / sticky bits are shown and preserved
|
||||
- chmod / chown now time out instead of spinning forever
|
||||
|
||||
- Fixed Chinese output over SSH turning into mojibake (multi-byte chars split across network chunks)
|
||||
- Fixed command history being lost or corrupted after accepting a Tab completion
|
||||
- Fixed editing keys (Ctrl+U / Ctrl+W, …) being written into command history (the "two commands merged" entries)
|
||||
- `cd ~/dir` and `cd $HOME/...` are now remembered; new terminals restore them
|
||||
- URL click ranges inside lines containing CJK characters no longer shift; degenerate sizes are no longer sent when the window is tiny
|
||||
- SSH sessions report the real exit code; ZMODEM timeouts no longer dump garbage into the terminal or the log
|
||||
|
||||
- A failed session restore no longer disables snapshot saving for the whole run
|
||||
- Monitor CPU / network history charts now redraw live; polling stops while the SSH workspace is hidden
|
||||
- Broadcast: closing one pane of a split no longer drops the other from broadcast targets; unchecking one target no longer clears the rest
|
||||
- Connection failures show the real reason; double-clicks no longer open duplicate sessions
|
||||
- Fixed "Close others / Close right" skipping tabs
|
||||
- Language switches apply from the first frame; active-tab text adapts after a theme switch (no more unreadable labels on light themes)
|
||||
- The private-key path field no longer claims to be a server-side path
|
||||
|
||||
- File drops can no longer navigate the window; external links are limited to http(s) / ftp; IPC is sender-checked
|
||||
- Stored passwords are only used for password-auth bookmarks (key-auth bookmarks no longer fall back to sending a password)
|
||||
- Connections / known hosts are written atomically — a crash can no longer wipe them
|
||||
- A crashed renderer has all of its sessions and logs reclaimed automatically
|
||||
- A corrupted custom theme no longer prevents the app from starting
|
||||
- Update-channel publishing is atomic — a failed publish can no longer break auto-update
|
||||
|
||||
## v1.0.13 - 2026-09-20
|
||||
|
||||
- Simplified Chinese, Traditional Chinese, English and Japanese, switchable in Settings → System → Language; applies instantly and persists
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# OpenTerminal 更新履歴
|
||||
|
||||
## v1.0.14 - 2026-09-20
|
||||
|
||||
- **約 250KB を超える**アップロードファイルが静かに破損する問題を修正(サイズは正しいのに内容のバイトが乱れる)
|
||||
- ダウンロードのたびにリモートのファイルハンドルが漏出し、転送のたびに SFTP チャネルが残る問題を修正。サーバーのプロトコル異常でアプリ全体が落ちることもなくなりました
|
||||
- 同名ファイルへのアップロードは上書き確認を表示。複数ファイル削除の部分失敗も正しく報告されます
|
||||
- 権限エディタの修正:不明なモードのファイルに誤って `chmod 000` を送らない。空の GID が新しい UID で置き換えられない。setuid / setgid / sticky ビットを表示・保持
|
||||
- chmod / chown にタイムアウトを追加し、遠端の無応答で永久にスピンしなくなりました
|
||||
|
||||
- SSH 出力の日本語・中文が文字化けする問題を修正(ネットワークパケットをまたいで分割されたマルチバイト文字)
|
||||
- Tab 補完を確定した後にコマンド履歴が失われる・壊れる問題を修正
|
||||
- Ctrl+U / Ctrl+W などの編集キーがコマンド履歴に書き込まれる問題を修正(「2 つのコマンドが連結される」症状)
|
||||
- `cd ~/ディレクトリ` や `cd $HOME/...` が正しく記憶され、新しいターミナルで復元されます
|
||||
- CJK 文字を含む行内のリンクのクリック範囲がずれない。極端に小さいサイズをターミナルに送らない
|
||||
- SSH セッションは実際の終了コードを表示。ZMODEM のタイムアウト・エラーでターミナルやログに文字化けを流し込まない
|
||||
|
||||
- セッション復元に失敗しても、その実行中のスナップショット保存が止まらなくなりました
|
||||
- モニターの CPU / ネットワーク履歴グラフがリアルタイムに再描画されます。SSH ワークスペース非表示中はポーリングを停止
|
||||
- ブロードキャスト:SSH 分割の片方を閉じてももう片方がブロードキャスト対象に残ります。チェックを外しても他の選択をクリアしない
|
||||
- 接続失敗時に実際の理由を表示。ダブルクリックで重複接続しない
|
||||
- 「他を閉じる / 右側を閉じる」がタブを飛ばして閉じる問題を修正
|
||||
- 言語切り替えが最初のフレームから有効。テーマ切り替え後にアクティブタブの文字色が自動で調整され、明るいテーマでも読める
|
||||
- 秘密鍵パス欄の説明を「この PC 上のパス」に修正(以前はサーバー上のパスと表示され、その通り入力すると接続に失敗していました)
|
||||
|
||||
- ウィンドウへのファイル ドロップによるナビゲーションを禁止。外部リンクは http(s) / ftp のみ。IPC に送信元チェックを追加
|
||||
- 保存パスワードはパスワード認証のブックマークにのみ使用(鍵認証のブックマークがパスワードをフォールバック送信しない)
|
||||
- 接続ブックマークやホスト指紋をアトミックに書き込み、クラッシュでデータが消えない
|
||||
- レンダラープロセスの異常終了時に、そのセッションとログを自動回収
|
||||
- 破損したカスタムテーマでアプリが起動しなくなる問題を修正
|
||||
- 更新チャネルの公開をアトミックに。公開中の失敗で自動更新が使えなくなることはなくなりました
|
||||
|
||||
## v1.0.13 - 2026-09-20
|
||||
|
||||
- 簡体字中国語 / 繁体字中国語 / English / 日本語 に対応。設定 → システム → 表示言語 から切り替え、即時反映され保存されます
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# OpenTerminal 更新日志
|
||||
|
||||
## v1.0.14 - 2026-09-20
|
||||
|
||||
- 修复上传 **大于约 250KB** 的文件会被静默损坏的问题(文件大小正确但内容出现字节错乱)
|
||||
- 修复每次下载泄漏一个远端文件句柄、每次传输遗留一个 SFTP 通道的问题;服务器协议异常不再导致整个应用退出
|
||||
- 上传遇到同名文件时先询问是否覆盖;删除多个文件时部分失败会如实报告
|
||||
- 权限编辑修复:未知权限的文件不再误发 `chmod 000`;GID 留空不再被误改成 UID 的值;正确显示并保留 setuid / setgid / sticky 特殊位
|
||||
- chmod / chown 增加超时,远端无响应不再永久转圈
|
||||
|
||||
- 修复 SSH 输出中文乱码(跨网络包被截断的多字节字符)
|
||||
- 修复按 Tab 接受补全后命令历史丢失或记录错乱的问题
|
||||
- 修复 Ctrl+U / Ctrl+W 等编辑键被写进命令历史(表现为历史里出现"两条命令拼接")
|
||||
- `cd ~/目录`、`cd $HOME/...` 现在能正确记忆目录,新终端按此恢复
|
||||
- 中文行内的网址链接点击范围不再错位;窗口尺寸过小时不再向终端发送异常尺寸
|
||||
- SSH 会话退出时显示真实退出码;ZMODEM 超时/出错不再向终端和日志倾倒乱码
|
||||
|
||||
- 启动恢复失败不再导致本次运行期间会话快照永久停止保存
|
||||
- 监控面板 CPU / 网络历史曲线现在会实时重绘;切到本地终端工作区时停止向服务器轮询
|
||||
- 广播输入:SSH 分屏关闭一侧不再让另一侧丢失广播目标;取消勾选一个目标不再清空其余选择
|
||||
- 连接失败时显示真实原因(密码错误 / 拒绝连接等),双击不再重复创建连接
|
||||
- 修复"关闭其他 / 关闭右侧"会漏关标签的问题
|
||||
- 界面语言切换首帧即生效;切换主题后活动标签文字颜色自动适配,不再出现浅色主题下看不清
|
||||
- 私钥路径字段提示纠正为"本机路径"(此前提示为服务器路径,照做会连接失败)
|
||||
|
||||
- 禁止把文件拖入窗口导航;外部链接仅允许 http(s) / ftp;IPC 增加来源校验
|
||||
- 仅密码认证的连接才使用存储的密码(密钥认证的连接不再回退发送密码)
|
||||
- 连接书签、主机指纹等配置改为原子写入,崩溃不再导致数据丢失
|
||||
- 渲染进程异常退出时自动回收其全部终端会话与日志
|
||||
- 自定义主题数据损坏不再导致应用无法启动
|
||||
- 内置更新通道发布改为原子操作,发布中途失败不再导致自动更新不可用
|
||||
|
||||
## v1.0.13 - 2026-09-20
|
||||
|
||||
- 支持简体中文 / 繁體中文 / English / 日本語,入口在「设置 → 系统 → 界面语言」,切换即时生效并持久化
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# OpenTerminal 更新日誌
|
||||
|
||||
## v1.0.14 - 2026-09-20
|
||||
|
||||
- 修復上傳 **大於約 250KB** 的檔案會被靜默損壞的問題(檔案大小正確但內容出現位元組錯亂)
|
||||
- 修復每次下載洩漏一個遠端檔案句柄、每次傳輸遺留一個 SFTP 通道的問題;伺服器協定異常不再導致整個應用結束
|
||||
- 上傳遇到同名檔案時先詢問是否覆蓋;刪除多個檔案時部分失敗會如實報告
|
||||
- 權限編輯修復:未知權限的檔案不再誤發 `chmod 000`;GID 留空不再被誤改成 UID 的值;正確顯示並保留 setuid / setgid / sticky 特殊位
|
||||
- chmod / chown 增加逾時,遠端無回應不再永久轉圈
|
||||
|
||||
- 修復 SSH 輸出中文亂碼(跨網路封包被截斷的多位元組字元)
|
||||
- 修復按 Tab 接受補全後命令歷史丟失或記錯亂的問題
|
||||
- 修復 Ctrl+U / Ctrl+W 等編輯鍵被寫進命令歷史(表現為歷史裡出現「兩條命令拼接」)
|
||||
- `cd ~/目錄`、`cd $HOME/...` 現在能正確記憶目錄,新終端按此恢復
|
||||
- 中文行內的網址連結點擊範圍不再錯位;視窗尺寸過小時不再向終端發送異常尺寸
|
||||
- SSH 連線結束時顯示真實離開代碼;ZMODEM 逾時/出錯不再向終端和日誌傾倒亂碼
|
||||
|
||||
- 啟動恢復失敗不再導致本次執行期間工作階段快照永久停止儲存
|
||||
- 監控面板 CPU / 網路歷史曲線現在會即時重繪;切到本地終端工作區時停止向伺服器輪詢
|
||||
- 廣播輸入:SSH 分屏關閉一側不再讓另一側丟失廣播目標;取消勾選一個目標不再清空其餘選擇
|
||||
- 連線失敗時顯示真實原因(密碼錯誤/拒絕連線等),雙擊不再重複建立連線
|
||||
- 修復「關閉其他 / 關閉右側」會漏關標籤的問題
|
||||
- 介面語言切換首影格即生效;切換主題後活動標籤文字顏色自動適配,不再出現淺色主題下看不清
|
||||
- 私鑰路徑欄位提示更正為「本機路徑」(此前提示為伺服器路徑,照做會連線失敗)
|
||||
|
||||
- 禁止把檔案拖入視窗導覽;外部連結僅允許 http(s) / ftp;IPC 增加來源校驗
|
||||
- 僅密碼認證的連線才使用儲存的密碼(金鑰認證的連線不再回退發送密碼)
|
||||
- 連線書籤、主機指紋等設定改為原子寫入,當機不再導致資料丟失
|
||||
- 渲染程序異常結束時自動回收其全部終端連線與日誌
|
||||
- 自訂主題資料損壞不再導致應用無法啟動
|
||||
- 內建更新通道發布改為原子操作,發布中途失敗不再導致自動更新不可用
|
||||
|
||||
## v1.0.13 - 2026-09-20
|
||||
|
||||
- 支援簡體中文 / 繁體中文 / English / 日本語,入口在「設定 → 系統 → 介面語言」,切換即時生效並持久化
|
||||
|
||||
@@ -9,6 +9,7 @@ npm install # 已安装可跳过
|
||||
npm run dev # 开发模式(热更新)
|
||||
npm run build # 生产构建到 out/
|
||||
npm run typecheck # 全量类型检查
|
||||
npm test # 重建测试 bundle 后依次跑离线测试(详见「测试」)
|
||||
```
|
||||
|
||||
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||
@@ -42,7 +43,7 @@ npm run typecheck # 全量类型检查
|
||||
|
||||
**效率工具**
|
||||
- 广播输入:勾选 ≥2 个终端后同步键入,标签带广播标记
|
||||
- 快捷输入面板:右下角浮层,命令历史 + 命令库(支持 `{{param}}` 变量)+ 一键执行
|
||||
- 命令面板:侧边栏「命令」标签分「历史」/「命令库」两页,历史按会话去重,命令库支持分组与一键运行
|
||||
- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭)
|
||||
- 会话日志:手动启停、纯文本落盘
|
||||
- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、全局唤起/隐藏(可配)
|
||||
@@ -55,9 +56,21 @@ npm run typecheck # 全量类型检查
|
||||
- 单实例运行:重复启动唤出已有窗口
|
||||
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
|
||||
|
||||
**界面与语言**
|
||||
- 四种界面语言:简体中文 / 繁體中文 / English / 日本語(设置 → 系统 → 界面语言,切换即时生效并持久化;antd 组件内置文案一并跟随)
|
||||
- 更新日志随安装包内置,「关于」页离线可看,并按当前界面语言显示(某版本缺翻译时回退简体中文)
|
||||
|
||||
**测试**
|
||||
|
||||
`npm test` 会先重建 esbuild bundle,再依次跑下列测试(无需服务器与凭据):
|
||||
|
||||
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
|
||||
- `node tests/ssh-session-e2e.mjs` — 真实会话路由层端到端(需先跑 esbuild 打包命令,见脚本头注释)
|
||||
- `node tests/ssh-session-e2e.mjs` — 会话路由层端到端(SSH 数据面、replay、resize、kill)
|
||||
- `node tests/sysinfo-e2e.mjs` — 服务器监控轮询端到端(META、采样速率、stopPolling)
|
||||
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
|
||||
- `node tests/commands-store.mjs` / `node tests/settings-store.mjs` — 命令库/历史/会话日志与设置清洗器
|
||||
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
|
||||
- 真实服务器测试(需 `JD_HOST/JD_USER/JD_PASS`,不在 `npm test` 内):`tests/sftp-real.mjs`、`tests/sftp-chmod.mjs`
|
||||
|
||||
## 架构
|
||||
|
||||
|
||||
+12
-2
@@ -16,6 +16,7 @@
|
||||
| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) |
|
||||
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
|
||||
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
|
||||
| M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) |
|
||||
|
||||
## M2 — SSH 远程会话 ✅
|
||||
|
||||
@@ -96,11 +97,20 @@
|
||||
- [x] 标签页视觉打磨:圆角胶囊 + 描边 + 独立关闭按钮
|
||||
- [x] 本地 PTY 声明终端能力(TERM/COLORTERM/TERM_PROGRAM),TUI 程序不再降级配色
|
||||
|
||||
## M10 — 多语言与体验细节 ✅
|
||||
|
||||
- [x] 界面语言(zh-CN / zh-TW / en / ja):全应用文案抽到 `src/shared/i18n/dicts`,设置 → 系统 → 界面语言,切换即时生效并持久化;antd 组件内置文案随语言切换
|
||||
- [x] 更新日志随安装包内置(`CHANGELOG*.md?raw`),「关于」页离线可看,按界面语言显示、缺翻译回退简体中文
|
||||
- [x] 全局唤起快捷键改为按键录制(Esc 取消 / 退格清空;必须带修饰键或功能键,避免误占普通按键)
|
||||
- [x] 终端右上角工具条可配置:打开工作区目录(随 `cd` 跟踪)、记录会话日志、打开日志目录
|
||||
- [x] 设置健壮性:清洗器修复而不丢弃(priority 字符串、0/1 布尔、缺前景色),异常写 `settings-warnings.log`;设置写入串行化;「每次询问」关闭行为不再被静默改回
|
||||
- [x] 「输入建议」「记录命令历史」默认关闭;浅色主题标签栏/设置弹窗对比度修复;会话日志改为每文件缓冲写入
|
||||
|
||||
## 工程约定(贯穿各阶段)
|
||||
|
||||
1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端
|
||||
2. **原生依赖**:优先选带预编译二进制的包(@lydell/node-pty 模式),避免用户机器装构建链
|
||||
3. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测
|
||||
3. **测试**:main 进程服务(pty/ssh/sftp/commands/settings/zmodem)先用 esbuild 打成 CJS/ESM bundle,再由 `tests/*.mjs` 以纯 Node 断言跑(`npm test`;无 vitest、无需服务器);渲染层交互靠 dev 手测
|
||||
4. **里程碑验收后打 git tag**
|
||||
|
||||
## 已知待办(M1 遗留)
|
||||
@@ -113,4 +123,4 @@
|
||||
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等)
|
||||
- [ ] rendererMode webgl 的终端数量上限自动降级(大量终端实例时回退 DOM 渲染)
|
||||
- [ ] 会话数据在面板间拖拽迁移(dockview 原生支持面板拖动,已具备)
|
||||
- [ ] 界面语言切换(中/英):需先把全应用文案抽为 i18n 资源目录(一次完整重构),设置页预留「系统」分区
|
||||
- [x] ~~界面语言切换~~(✅ v1.0.13:zh-CN / zh-TW / en / ja,文案抽到 `src/shared/i18n/dicts`,设置 → 系统 → 界面语言)
|
||||
+18
-14
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前版本与仓库
|
||||
|
||||
- v0.1.0(版本重置:作为全新开源项目从 0.1.0 起步),远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- v1.0.13,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- 开源协议:MIT(LICENSE)
|
||||
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
|
||||
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
|
||||
@@ -24,6 +24,7 @@
|
||||
| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) |
|
||||
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
|
||||
| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ |
|
||||
| M10 多语言界面(zh-CN/zh-TW/en/ja)+离线多语言更新日志+按键录制+可配工具条+设置健壮性 | ✅(v1.0.13) |
|
||||
|
||||
## M8 实现要点(v0.8.0,已落地)
|
||||
|
||||
@@ -42,31 +43,34 @@
|
||||
|
||||
- **广播输入**:渲染层 zustand(broadcastStore)维护 enabled/targets/sessions;TerminalView 全部写路径(onData/补全/粘贴/Workspace runCommand)走 writeBroadcast 扇出;目标 <2 自动禁用;tab 目标圆点 + 按钮计数徽标
|
||||
- **ZMODEM**:主进程引擎(src/main/zmodem.ts,仅 SSH 会话;本地 pty 走 ConPTY 只给 UTF-8 字符串,二进制会损坏——不支持,注释已说明);Sentry 常驻分流非 zmodem 字节;offer→渲染层选文件/目录→respond;传输期抑制 PTY_DATA/replay/日志并丢弃用户键入;offer 120s/传输 90s 看门狗;进度复用 TransferPanel(kind=zmodem-upload/download);测试 tests/zmodem-e2e.mjs 用第二个 zmodem.js Sentry 模拟远端,双向内容一致性断言
|
||||
- **快捷输入面板**:QuickInputPanel 右下 fixed 浮层(bottom:44 避让 TransferPanel),发送经 writeBroadcast(广播感知),列表打开沿刷新
|
||||
- **快捷键**:Ctrl+=/-/0 字号(main.tsx capture 监听,xterm-helper-textarea 放行——隐藏 textarea 曾被误判为输入框导致终端聚焦时失效,已修);globalShowHide accelerator(globalShortcuts.ts,设置页系统分区可配,注册失败仅 warn);Ctrl+PgUp/PgDn 面板循环(Workspace capture 监听)
|
||||
- **打包**:electron-builder.yml(msi 固定 upgradeCode 5ab9f79e-e4eb-4052-9df6-3af3a301ab0a + nsis;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址)
|
||||
|
||||
## 发布流程(下一版本照抄)
|
||||
|
||||
1. package.json version 升位 → `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)
|
||||
2. `git tag vX.Y.Z && git push origin main vX.Y.Z`
|
||||
3. Gitea API 建发布:POST /api/v1/repos/admin/OpenTerminal/releases(中文 body 必须走 UTF-8 文件 --data-binary @file,shell 内联会坏)
|
||||
4. 附件:POST .../releases/{id}/assets?name=(msi/exe/blockmap/latest.yml,实测 141MB 可传)
|
||||
5. 更新通道:DELETE .../api/packages/admin/generic/openterminal-update/stable(旧版)→ PUT 同 URL 依次上传 latest.yml / exe.blockmap / exe
|
||||
6. 校验:curl 无 token 拉 latest.yml 应 200 且 version 正确
|
||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)
|
||||
2. `node scripts/release.cjs <版本号> --skip-github`:脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap
|
||||
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(同一版本可重复运行:release 复用、已传资产跳过)
|
||||
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||
5. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像只推代码/tag,不随版本发 release 资产)
|
||||
|
||||
## 测试(全部通过,改动后必跑)
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -p tsconfig.node.json && npx tsc --noEmit -p tsconfig.web.json
|
||||
npm run typecheck # tsconfig.node.json + tsconfig.web.json
|
||||
npm run build
|
||||
npm test # = node tests/build-bundles.cjs && 下面 7 个测试(依次,全部离线可跑)
|
||||
node tests/ssh-loopback.mjs
|
||||
npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty --external:ssh2 --alias:electron=./tests/electron-stub.cjs && node tests/ssh-session-e2e.mjs
|
||||
node tests/sysinfo-e2e.mjs
|
||||
npx esbuild tests/hl-split-smoke.mjs --bundle --platform=node --format=cjs --outfile=tests/.hl-split-smoke.cjs && node tests/.hl-split-smoke.cjs
|
||||
node tests/commands-store.mjs
|
||||
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/告警日志)
|
||||
node tests/.hl-split-smoke.cjs
|
||||
node tests/zmodem-e2e.mjs
|
||||
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期):
|
||||
node tests/ssh-session-e2e.mjs
|
||||
node tests/sysinfo-e2e.mjs
|
||||
# `node tests/build-bundles.cjs` 单独重建全部 6 个 esbuild bundle(别名只存在于该脚本):
|
||||
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .settings-store.cjs
|
||||
# .zmodem-e2e.cjs .hl-split-smoke.cjs —— 少 --alias:@shared=./src/shared 会编译失败
|
||||
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
|
||||
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
|
||||
# 打包:npm run dist(产物 release/)
|
||||
```
|
||||
@@ -94,4 +98,4 @@ node tests/zmodem-e2e.mjs
|
||||
|
||||
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
|
||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键
|
||||
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、i18n(中英切换)、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||
Generated
+5
-3
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -32,6 +33,7 @@
|
||||
"electron": "^44.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
@@ -4291,7 +4293,7 @@
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "Bill",
|
||||
@@ -12,6 +12,7 @@
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/.hl-split-smoke.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
|
||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
},
|
||||
@@ -40,6 +41,7 @@
|
||||
"electron": "^44.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
|
||||
+113
-20
@@ -1,6 +1,7 @@
|
||||
// OpenTerminal release publisher
|
||||
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea]
|
||||
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea] [--channel-only]
|
||||
// node scripts/release.cjs 1.0.2
|
||||
// node scripts/release.cjs 1.0.2 --channel-only # update channel only (repair)
|
||||
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
|
||||
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
|
||||
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
|
||||
@@ -11,14 +12,18 @@ const path = require('node:path')
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
const V = process.argv[2]
|
||||
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
|
||||
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea]')
|
||||
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea] [--channel-only]')
|
||||
process.exit(1)
|
||||
}
|
||||
const SKIP_GH = process.argv.includes('--skip-github')
|
||||
const SKIP_GITEA = process.argv.includes('--skip-gitea')
|
||||
const CHANNEL_ONLY = process.argv.includes('--channel-only')
|
||||
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
|
||||
// Trim before the comment check: an indented `# comment = x` is a comment,
|
||||
// not an env entry.
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.includes('=') && !l.startsWith('#'))
|
||||
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
|
||||
)
|
||||
@@ -31,10 +36,12 @@ const files = [
|
||||
for (const f of files) {
|
||||
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
|
||||
}
|
||||
// Guard rails: the artifacts must belong to the version being published, and
|
||||
// the tag must not exist yet — otherwise a re-run silently republishes a
|
||||
// *different* binary under an already-released version number, and installed
|
||||
// clients never see it (their version check compares numbers, not hashes).
|
||||
// Guard rails: the artifacts must belong to the version being published —
|
||||
// otherwise a re-run silently republishes a *different* binary under an
|
||||
// already-released version number, and installed clients never see it (their
|
||||
// version check compares numbers, not hashes). Re-running the same version is
|
||||
// supported (gitea() reuses an existing release), so this is a version check,
|
||||
// not a tag check.
|
||||
const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
||||
if (pkgVersion !== V) {
|
||||
console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`)
|
||||
@@ -62,7 +69,8 @@ if (ymlVersion !== V) {
|
||||
// the agent is passed explicitly on GitHub requests.
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
|
||||
const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined
|
||||
if (proxy) console.log('proxy for GitHub only:', proxy)
|
||||
// Never log the proxy URL verbatim: it may embed `user:password@`.
|
||||
if (proxy) console.log('proxy for GitHub only:', proxy.replace(/\/\/[^/@]*@/, '//'))
|
||||
|
||||
async function upload(url, file, token, authScheme, viaProxy = false) {
|
||||
const name = path.basename(file)
|
||||
@@ -88,15 +96,81 @@ async function upload(url, file, token, authScheme, viaProxy = false) {
|
||||
async function gitea() {
|
||||
console.log('=== Gitea release ===')
|
||||
const base = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
|
||||
const auth = { Authorization: `token ${env.GIT_TOKEN}` }
|
||||
// Idempotent: a re-run (e.g. `--channel-only` after a half-finished publish)
|
||||
// must not die on the release it created last time — reuse it, and skip assets
|
||||
// that are already attached.
|
||||
const existing = await fetch(`${base}/releases/tags/v${V}`, { headers: auth })
|
||||
let rel
|
||||
if (existing.ok) {
|
||||
rel = await existing.json()
|
||||
console.log('release already exists, reusing id =', rel.id)
|
||||
} else {
|
||||
const resp = await fetch(`${base}/releases`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
|
||||
})
|
||||
if (!resp.ok) { throw new Error(`Gitea release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
|
||||
const rel = await resp.json()
|
||||
if (resp.ok) {
|
||||
rel = await resp.json()
|
||||
console.log('release created, id =', rel.id)
|
||||
for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token')
|
||||
} else {
|
||||
const detail = (await resp.text()).slice(0, 300)
|
||||
// 409 = the tag already carries a release, created by an earlier partial run.
|
||||
const again = resp.status === 409 ? await fetch(`${base}/releases/tags/v${V}`, { headers: auth }) : undefined
|
||||
if (!again || !again.ok) throw new Error(`Gitea release create failed: ${resp.status} ${detail}`)
|
||||
rel = await again.json()
|
||||
console.log('release already exists, reusing id =', rel.id)
|
||||
}
|
||||
}
|
||||
const listed = await fetch(`${base}/releases/${rel.id}/assets`, { headers: auth })
|
||||
const attached = new Set(listed.ok ? (await listed.json()).map((a) => a.name) : [])
|
||||
for (const f of files) {
|
||||
const name = path.basename(f)
|
||||
if (attached.has(name)) { console.log(` asset ${name}: already attached, skipped`); continue }
|
||||
await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token')
|
||||
}
|
||||
}
|
||||
|
||||
/** Version the update channel serves right now (read from its latest.yml), or
|
||||
undefined when the channel is empty/unreachable. */
|
||||
async function channelVersion(base) {
|
||||
try {
|
||||
const resp = await fetch(`${base}/latest.yml`)
|
||||
if (!resp.ok) return undefined
|
||||
return (await resp.text()).match(/^version:\s*(\S+)/m)?.[1]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Size of a channel file, or -1 when it is not there. */
|
||||
async function channelFileSize(url) {
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'HEAD' })
|
||||
return resp.ok ? Number(resp.headers.get('content-length') ?? -1) : -1
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the previous version's binaries, once the new latest.yml is live. The old
|
||||
files keep serving until then, so nothing is ever removed up front. Best
|
||||
effort: leftovers cost disk, never correctness — latest.yml names the files
|
||||
clients fetch. */
|
||||
async function pruneChannel(base, previous) {
|
||||
if (!previous || previous === V) return
|
||||
for (const name of [`OpenTerminal-${previous}-setup.exe`, `OpenTerminal-${previous}-setup.exe.blockmap`]) {
|
||||
try {
|
||||
const resp = await fetch(`${base}/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `token ${env.GIT_TOKEN}` }
|
||||
})
|
||||
if (resp.ok) console.log(` removed previous ${name}`)
|
||||
} catch (e) {
|
||||
console.log(` previous ${name} not removed: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function giteaChannel() {
|
||||
@@ -110,30 +184,41 @@ async function giteaChannel() {
|
||||
// releases API 404s), but the generic package is publicly readable.
|
||||
[path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md']
|
||||
]
|
||||
// Validate before touching the channel: a missing file after the DELETE would
|
||||
// leave the update channel empty (clients then fall back to GitHub, which is
|
||||
// unreachable in China for most users).
|
||||
// Validate before touching the channel: uploading a payload-less latest.yml
|
||||
// would leave the update channel broken (clients then fall back to GitHub,
|
||||
// which is unreachable in China for most users).
|
||||
for (const [f] of channelFiles) {
|
||||
if (!fs.existsSync(f)) throw new Error(`missing channel file: ${f}`)
|
||||
}
|
||||
// latest.yml goes last: it is what makes clients start downloading, so the
|
||||
// payload must already be in place.
|
||||
// payload must already be in place. Nothing is deleted first — the previous
|
||||
// version keeps serving until every new file has landed, so an interrupted PUT
|
||||
// (ECONNRESET) can no longer leave the channel empty and unrepairable.
|
||||
const isLatest = ([f]) => path.basename(f) === 'latest.yml'
|
||||
const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)]
|
||||
// Replace the version only once every file is known to be present on disk.
|
||||
const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
|
||||
console.log(' delete old stable:', del.status)
|
||||
const previous = await channelVersion(base)
|
||||
for (const [f, name] of ordered) {
|
||||
const stat = fs.statSync(f)
|
||||
const resp = await fetch(`${base}/${encodeURIComponent(name)}`, {
|
||||
const url = `${base}/${encodeURIComponent(name)}`
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) },
|
||||
body: fs.createReadStream(f),
|
||||
duplex: 'half'
|
||||
})
|
||||
console.log(` put ${name}: HTTP ${resp.status}`)
|
||||
if (!resp.ok) throw new Error(`channel put failed (${resp.status}) for ${name}: ${(await resp.text()).slice(0, 200)}`)
|
||||
if (!resp.ok) {
|
||||
const detail = (await resp.text()).slice(0, 200)
|
||||
// Re-publishing the same version can hit an already-stored file; accept it
|
||||
// only when the stored copy has exactly the same size.
|
||||
if ((resp.status === 409 || resp.status === 422) && (await channelFileSize(url)) === stat.size) {
|
||||
console.log(` ${name} is already published with the same size, kept`)
|
||||
continue
|
||||
}
|
||||
throw new Error(`channel put failed (${resp.status}) for ${name}: ${detail}`)
|
||||
}
|
||||
}
|
||||
await pruneChannel(base, previous)
|
||||
}
|
||||
|
||||
async function github() {
|
||||
@@ -159,6 +244,14 @@ async function github() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// --channel-only: the release and its assets already exist (or are not needed),
|
||||
// so republish just the update channel — the repair path for a channel upload
|
||||
// that died halfway.
|
||||
if (CHANNEL_ONLY) {
|
||||
await giteaChannel()
|
||||
console.log('done (channel only)')
|
||||
return
|
||||
}
|
||||
if (!SKIP_GITEA) { await gitea(); await giteaChannel() }
|
||||
if (!SKIP_GH) await github()
|
||||
console.log('done')
|
||||
|
||||
@@ -52,8 +52,10 @@ const sync = ({ notes, changelog, header, required }) => {
|
||||
}
|
||||
|
||||
const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${body}\n`
|
||||
// Function replacement, not a string: a notes body containing `$&`, `$1`, `` $` ``
|
||||
// or `$'` would otherwise be expanded by String.replace and corrupt the merge.
|
||||
const updated = existing
|
||||
? existing.replace(new RegExp(`^(${header}\\n\\n)`), `$1${section}\n`)
|
||||
? existing.replace(new RegExp(`^(${header}\\n\\n)`), (_m, head) => `${head}${section}\n`)
|
||||
: `${header}\n\n${section}`
|
||||
fs.writeFileSync(changelogPath, updated, 'utf8')
|
||||
console.log(`${changelog}: added v${pkgVersion} (${body.split('\n').length} lines)`)
|
||||
|
||||
+10
-6
@@ -27,6 +27,7 @@ import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||
import { loadSettings } from './settingsStore'
|
||||
import { LogSanitizer } from './logSanitizer'
|
||||
import { writeJson } from './store'
|
||||
|
||||
/** Upper bound on recorded history entries when no limit is configured. */
|
||||
const HISTORY_CAP = 500
|
||||
@@ -113,8 +114,7 @@ export class CommandsStore {
|
||||
}
|
||||
|
||||
private saveCommands(data: CommandsFile): void {
|
||||
mkdirSync(join(this.file, '..'), { recursive: true })
|
||||
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
|
||||
writeJson(this.file, data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +132,9 @@ export class CommandsStore {
|
||||
* existing history is left untouched rather than cleared.
|
||||
*/
|
||||
recordCommand(cmd: string): void {
|
||||
const trimmed = cmd.trim()
|
||||
// The line arrives from the renderer's line buffer, which can still hold
|
||||
// editing bytes (Ctrl+U, a stray ESC): only the printable text is a command.
|
||||
const trimmed = cmd.replace(/[\x00-\x1f\x7f]/g, '').trim()
|
||||
if (!trimmed) return
|
||||
const { historyEnabled, historyLimit } = loadHistoryPrefs()
|
||||
if (!historyEnabled) return
|
||||
@@ -246,11 +248,10 @@ export class CommandsStore {
|
||||
/** Write the full log index so listSessionLogs survives restart. */
|
||||
private persistIndex(): void {
|
||||
try {
|
||||
mkdirSync(this.logsDir, { recursive: true })
|
||||
const all = Array.from(this.metasByFile.values()).sort(
|
||||
(a, b) => b.startedAt - a.startedAt
|
||||
)
|
||||
writeFileSync(this.indexFile, JSON.stringify(all, null, 2), 'utf8')
|
||||
writeJson(this.indexFile, all)
|
||||
} catch {
|
||||
// best effort: never break the session over an index write failure
|
||||
}
|
||||
@@ -269,7 +270,10 @@ export class CommandsStore {
|
||||
if (prev) this.finishLog(prev)
|
||||
|
||||
mkdirSync(this.logsDir, { recursive: true })
|
||||
const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log`
|
||||
// The id comes from the renderer and lands in a file name: keep only the
|
||||
// characters a session id is made of so it can never reach outside logsDir.
|
||||
const safeId = sessionId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || 'session'
|
||||
const fileName = `${stamp()}-${safeId}.log`
|
||||
const file = join(this.logsDir, fileName)
|
||||
// Create the file eagerly so the first async append cannot race a missing fd.
|
||||
describeFile(file)
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import { safeStorage } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
import { writeJson } from './store'
|
||||
|
||||
const ENC_SUFFIX = '_enc'
|
||||
const PLAIN_PREFIX = 'plain:'
|
||||
@@ -128,8 +128,7 @@ export class ConnectionsStore {
|
||||
}
|
||||
|
||||
private save(list: StoredConnection[]): void {
|
||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
|
||||
writeJson(this.filePath, list)
|
||||
}
|
||||
|
||||
listConnections(): SshConnection[] {
|
||||
|
||||
@@ -37,6 +37,10 @@ export function resolveCwd(current: string | undefined, rawArg: string): string
|
||||
}
|
||||
// `cd -` means the shell's previous directory, which we cannot know here.
|
||||
if (arg === '-') return null
|
||||
// `cd ~/src`, `cd $HOME/x`, `cd %USERPROFILE%\x`: expand the home prefix
|
||||
// before the absolute/relative decision below.
|
||||
const homePrefix = arg.match(/^(?:~|\$HOME)(?=[/\\])/) ?? arg.match(/^%USERPROFILE%(?=[/\\])/i)
|
||||
if (homePrefix) arg = homedir() + arg.slice(homePrefix[0].length)
|
||||
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
|
||||
// current directory, which the shell never tells us — path.isAbsolute('d:')
|
||||
// is false, so without this branch it resolves against the base and dies on
|
||||
|
||||
+55
-21
@@ -1,8 +1,8 @@
|
||||
import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { registerIpc } from './ipc'
|
||||
import { killAllPtys } from './pty'
|
||||
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||
import { killAllPtys, killPtysByOwner } from './pty'
|
||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
@@ -21,6 +21,21 @@ function showOrCreate(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a link to the OS browser. Only the web schemes are allowed: a renderer
|
||||
* that asked to open `file:`/`ms-*:`/anything else must not reach the shell.
|
||||
*/
|
||||
function openExternal(url: string): void {
|
||||
try {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol === 'http:' || protocol === 'https:' || protocol === 'ftp:') {
|
||||
void shell.openExternal(url)
|
||||
}
|
||||
} catch {
|
||||
// unparsable target: nothing to open
|
||||
}
|
||||
}
|
||||
|
||||
// Dev runs get their own userData directory (settings, session snapshot,
|
||||
// command history, logs) *and* their own single-instance lock — both are keyed
|
||||
// on that path. Without this a dev instance fights the installed build: it
|
||||
@@ -32,11 +47,34 @@ if (!app.isPackaged) {
|
||||
|
||||
// Single instance: a second launch just surfaces the existing window (pulls
|
||||
// it out of the tray if hidden there) instead of starting another process.
|
||||
// The refused instance skips the whole startup path: `app.quit()` only asks
|
||||
// the app to exit, so running the `whenReady` init behind it left a second
|
||||
// process with IPC, a tray and an updater but no window.
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => showOrCreate())
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
registerUpdateIpc()
|
||||
// Before the window exists: a renderer-triggered check must not run against
|
||||
// the updater's defaults (feed, proxy, autoDownload are set in here).
|
||||
configureAutoUpdater()
|
||||
// OS-level effects (login item, sleep blocker) must apply even if the
|
||||
// settings dialog is never opened this run.
|
||||
applyStartupSystemSettings(loadSettings())
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
// to be rebuilt whenever the interface language changes.
|
||||
onLanguageChange(() => refreshTrayMenu())
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
@@ -95,11 +133,25 @@ function createWindow(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// A renderer that crashed or was destroyed without running its own cleanup
|
||||
// (normal close/reload kills its sessions via beforeunload first) leaves
|
||||
// orphaned PTY/SSH transports behind — and session logs that keep appending.
|
||||
const dropOwnedSessions = (): void => killPtysByOwner(win.webContents.id)
|
||||
win.webContents.on('render-process-gone', dropOwnedSessions)
|
||||
win.webContents.on('destroyed', dropOwnedSessions)
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// Dropping a local .html file on the window is a navigation like any other,
|
||||
// and the new document would inherit this window's privileged preload. Only
|
||||
// the app's own page may (re)load here.
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (!isTrustedRendererUrl(url)) event.preventDefault()
|
||||
})
|
||||
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
if (devUrl) {
|
||||
win.loadURL(devUrl)
|
||||
@@ -115,24 +167,6 @@ process.on('uncaughtException', (err) => {
|
||||
console.error('[main] uncaughtException:', err)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
registerUpdateIpc()
|
||||
// OS-level effects (login item, sleep blocker) must apply even if the
|
||||
// settings dialog is never opened this run.
|
||||
applyStartupSystemSettings(loadSettings())
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
// to be rebuilt whenever the interface language changes.
|
||||
onLanguageChange(() => refreshTrayMenu())
|
||||
configureAutoUpdater()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Let window 'close' events pass through so teardown completes.
|
||||
markQuitting()
|
||||
|
||||
+66
-2
@@ -1,7 +1,10 @@
|
||||
import { app, ipcMain, shell } from 'electron'
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
import { homedir } from 'os'
|
||||
import { statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||
import { t } from '../shared/i18n'
|
||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
@@ -33,6 +36,66 @@ import { createPty, killPty, resizePty, writePty, openSession, configureSessionR
|
||||
import { respondZmodem } from './zmodem'
|
||||
import type { ZmodemResponse } from '../shared/ipc'
|
||||
|
||||
/** The renderer document this app loads (dev builds load it from vite instead). */
|
||||
const RENDERER_FILE = join(__dirname, '../renderer/index.html')
|
||||
|
||||
/**
|
||||
* True only for a document the app itself loaded: the bundled renderer file, or
|
||||
* in dev anything served by the vite dev server. Used both to refuse a
|
||||
* navigation away from the app page and to refuse IPC from a frame that is not
|
||||
* it — a window that navigated elsewhere would still hold this preload bridge,
|
||||
* which is the whole main-process API (`createPty` included).
|
||||
*/
|
||||
export function isTrustedRendererUrl(raw: string): boolean {
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
try {
|
||||
const target = new URL(raw)
|
||||
if (devUrl) return target.origin === new URL(devUrl).origin
|
||||
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeListener = (event: IpcMainInvokeEvent, ...args: any[]) => unknown
|
||||
type EventListener = (event: IpcMainEvent, ...args: any[]) => void
|
||||
|
||||
let senderGuardInstalled = false
|
||||
|
||||
/**
|
||||
* Channels are registered from four modules (this one, settingsStore,
|
||||
* sessionState, updater) and Electron exposes no global IPC hook, so the sender
|
||||
* check is installed once here — the single entry point all of them are
|
||||
* registered through — rather than repeated at every registration site.
|
||||
*
|
||||
* Handlers never saw the sender before: any frame that managed to navigate
|
||||
* could drive the main process. Refused invokes reject the renderer's promise;
|
||||
* refused sends are dropped.
|
||||
*/
|
||||
function installSenderGuard(): void {
|
||||
if (senderGuardInstalled) return
|
||||
senderGuardInstalled = true
|
||||
const rawHandle = ipcMain.handle.bind(ipcMain) as (
|
||||
channel: string,
|
||||
listener: InvokeListener
|
||||
) => void
|
||||
const rawOn = ipcMain.on.bind(ipcMain) as (channel: string, listener: EventListener) => void
|
||||
|
||||
ipcMain.handle = ((channel: string, listener: InvokeListener) =>
|
||||
rawHandle(channel, (event, ...args) => {
|
||||
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) {
|
||||
throw new Error(`[ipc] refused "${channel}" from an untrusted frame`)
|
||||
}
|
||||
return listener(event, ...args)
|
||||
})) as typeof ipcMain.handle
|
||||
|
||||
ipcMain.on = ((channel: string, listener: EventListener) =>
|
||||
rawOn(channel, (event, ...args) => {
|
||||
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) return
|
||||
listener(event, ...args)
|
||||
})) as typeof ipcMain.on
|
||||
}
|
||||
|
||||
let commandsStore: CommandsStore | undefined
|
||||
|
||||
/** M5: inject a custom command store (tests) or default to userData-backed. */
|
||||
@@ -48,6 +111,7 @@ export function getCommandsStore(): CommandsStore {
|
||||
}
|
||||
|
||||
export function registerIpc(): void {
|
||||
installSenderGuard()
|
||||
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
|
||||
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
|
||||
const cmds = getCommandsStore()
|
||||
@@ -82,8 +146,8 @@ export function registerIpc(): void {
|
||||
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
|
||||
)
|
||||
|
||||
ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts))
|
||||
ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts))
|
||||
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
|
||||
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
|
||||
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
|
||||
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
|
||||
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { writeJson } from './store'
|
||||
|
||||
export interface KnownHostEntry {
|
||||
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
|
||||
@@ -63,8 +63,7 @@ export class KnownHostsStore {
|
||||
}
|
||||
|
||||
private save(shape: KnownHostsStoreShape): void {
|
||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8')
|
||||
writeJson(this.filePath, shape)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-2
@@ -1,7 +1,13 @@
|
||||
import { app } from 'electron'
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { LayoutMeta } from '../shared/ipc'
|
||||
import { writeJson } from './store'
|
||||
|
||||
/** Layout ids reach this module straight from the renderer and are joined into
|
||||
* a path, so only a plain id is accepted — `..`, separators and drive prefixes
|
||||
* would otherwise escape `userData/layouts`. */
|
||||
const LAYOUT_ID = /^[A-Za-z0-9._-]{1,64}$/
|
||||
|
||||
const layoutsDir = (): string => {
|
||||
const dir = join(app.getPath('userData'), 'layouts')
|
||||
@@ -9,6 +15,10 @@ const layoutsDir = (): string => {
|
||||
return dir
|
||||
}
|
||||
|
||||
function isValidLayoutId(id: unknown): id is string {
|
||||
return typeof id === 'string' && LAYOUT_ID.test(id)
|
||||
}
|
||||
|
||||
function layoutPath(id: string): string {
|
||||
return join(layoutsDir(), `${id}.json`)
|
||||
}
|
||||
@@ -39,6 +49,7 @@ export function listLayouts(): LayoutMeta[] {
|
||||
}
|
||||
|
||||
export function getLayout(id: string): string | null {
|
||||
if (!isValidLayoutId(id)) return null
|
||||
try {
|
||||
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
|
||||
return typeof file.json === 'string' ? file.json : null
|
||||
@@ -48,11 +59,17 @@ export function getLayout(id: string): string | null {
|
||||
}
|
||||
|
||||
export function saveLayout(meta: LayoutMeta, json: string): void {
|
||||
if (!isValidLayoutId(meta.id)) {
|
||||
throw new Error(`[layouts] invalid layout id: ${String(meta.id)}`)
|
||||
}
|
||||
const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json }
|
||||
writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8')
|
||||
writeJson(layoutPath(meta.id), file)
|
||||
}
|
||||
|
||||
export function deleteLayout(id: string): void {
|
||||
if (!isValidLayoutId(id)) {
|
||||
throw new Error(`[layouts] invalid layout id: ${String(id)}`)
|
||||
}
|
||||
try {
|
||||
rmSync(layoutPath(id))
|
||||
} catch (err) {
|
||||
|
||||
@@ -65,7 +65,7 @@ export class LogSanitizer {
|
||||
this.cr = true
|
||||
} else if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
|
||||
} else if (c === '\t' || c >= ' ') {
|
||||
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||
if (this.alt) this.altDirty = true
|
||||
else this.line += c
|
||||
@@ -97,6 +97,10 @@ export class LogSanitizer {
|
||||
this.mode = 'text'
|
||||
} else if (this.seq.length < 1024) {
|
||||
this.seq += c
|
||||
} else {
|
||||
// Runaway sequence past the cap: resync as plain text instead of
|
||||
// swallowing all following output while stuck in 'csi'.
|
||||
this.mode = 'text'
|
||||
}
|
||||
break
|
||||
case 'osc':
|
||||
@@ -124,10 +128,16 @@ export class LogSanitizer {
|
||||
this.cr = false
|
||||
// A trailing \r at stop: treat as line ending rather than overwrite.
|
||||
out += this.emitLine()
|
||||
} else if (this.line && !this.alt) {
|
||||
} else if (this.line) {
|
||||
if (this.alt) {
|
||||
// Stopped mid-TUI: route through emitLine so the suppressed span gets
|
||||
// its [TUI output omitted] marker via altDirty below.
|
||||
out += this.emitLine()
|
||||
} else {
|
||||
out += this.line
|
||||
this.line = ''
|
||||
}
|
||||
}
|
||||
if (this.altDirty) {
|
||||
this.altDirty = false
|
||||
out += MARKER()
|
||||
|
||||
+55
-12
@@ -2,6 +2,7 @@ import { t } from '../shared/i18n'
|
||||
import { spawn, type IPty } from '@lydell/node-pty'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { StringDecoder } from 'string_decoder'
|
||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
||||
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
||||
import { broadcast } from './broadcast'
|
||||
@@ -55,7 +56,7 @@ function safeStopLog(id: string): void {
|
||||
* shell; the generic PTY_* (data plane) channels address both. PTY_DATA /
|
||||
* PTY_EXIT broadcasts are identical for both kinds.
|
||||
*/
|
||||
type Session = { kind: 'local'; pty: IPty } | { kind: 'ssh'; ssh: SshSessionHandle }
|
||||
type Session = { kind: 'local'; pty: IPty; owner?: number } | { kind: 'ssh'; ssh: SshSessionHandle; owner?: number }
|
||||
|
||||
/** The live ssh2 client behind an ssh session, or undefined. */
|
||||
export function getSshClient(id: string): SshSessionHandle['client'] | undefined {
|
||||
@@ -73,11 +74,28 @@ const sessions = new Map<string, Session>()
|
||||
const REPLAY_CAP = 64 * 1024
|
||||
const replayBuffers = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Per-session UTF-8 decoder for the ssh data plane. ssh2 hands out raw TCP
|
||||
* chunks, so a multi-byte character split across a chunk boundary must be
|
||||
* held over by the decoder instead of decoding each chunk alone (which turns
|
||||
* the split char into U+FFFD — systematic for CJK output).
|
||||
*/
|
||||
const sshDecoders = new Map<string, StringDecoder>()
|
||||
|
||||
function appendReplay(id: string, data: string): void {
|
||||
const prev = replayBuffers.get(id) ?? ''
|
||||
const next = prev.length + data.length > REPLAY_CAP
|
||||
? (prev + data).slice(prev.length + data.length - REPLAY_CAP)
|
||||
: prev + data
|
||||
const combined = prev + data
|
||||
let next = combined.length > REPLAY_CAP ? combined.slice(combined.length - REPLAY_CAP) : combined
|
||||
if (next !== combined) {
|
||||
// The cut may have landed inside an escape sequence or a surrogate pair.
|
||||
// Resync at the next ESC (which starts a complete sequence) and drop a
|
||||
// leading lone low surrogate so xterm neither swallows later output nor
|
||||
// renders a replacement char.
|
||||
const esc = next.indexOf('\x1b')
|
||||
if (esc > 0 && esc < 64) next = next.slice(esc)
|
||||
const code = next.charCodeAt(0)
|
||||
if (code >= 0xdc00 && code <= 0xdfff) next = next.slice(1)
|
||||
}
|
||||
replayBuffers.set(id, next)
|
||||
}
|
||||
|
||||
@@ -148,7 +166,7 @@ function defaultShell(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
export function createPty(opts: PtyCreateOptions = {}, owner?: number): PtyCreateResult {
|
||||
const id = randomUUID()
|
||||
const shell = opts.shell ?? defaultShell()
|
||||
// A remembered directory can be gone by the next launch (renamed, unmounted,
|
||||
@@ -185,7 +203,7 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
}
|
||||
|
||||
const pty = spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
|
||||
sessions.set(id, { kind: 'local', pty })
|
||||
sessions.set(id, { kind: 'local', pty, owner })
|
||||
replayBuffers.set(id, '')
|
||||
|
||||
pty.onData((data) => {
|
||||
@@ -218,9 +236,9 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
* Open a session. `local` reuses createPty; `ssh` goes through the ssh service
|
||||
* and resolves only once the shell stream is ready to stream data.
|
||||
*/
|
||||
export async function openSession(opts: SessionOpenOptions): Promise<{ id: string }> {
|
||||
export async function openSession(opts: SessionOpenOptions, owner?: number): Promise<{ id: string }> {
|
||||
if (opts.kind === 'local') {
|
||||
return { id: createPty().id }
|
||||
return { id: createPty(undefined, owner).id }
|
||||
}
|
||||
|
||||
const deps = runtimeDeps
|
||||
@@ -248,7 +266,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
||||
broadcast: deps.broadcast,
|
||||
promptHostKey: deps.promptHostKey
|
||||
})
|
||||
sessions.set(handle.id, { kind: 'ssh', ssh: handle })
|
||||
sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner })
|
||||
sshDecoders.set(handle.id, new StringDecoder('utf8'))
|
||||
|
||||
// ZMODEM (M6): attach the in-process engine to the raw ssh stream. Only ssh
|
||||
// sessions are supported -- node-pty/Windows ConPTY hands out UTF-8 strings
|
||||
@@ -258,7 +277,9 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
||||
// writePty drops user keystrokes while isZmodemActive is true).
|
||||
attachZmodem(handle.id, {
|
||||
broadcast: (channel, ...args) => deps.broadcast(channel, ...args),
|
||||
toTerminal: (id, text) => {
|
||||
toTerminal: (id, data) => {
|
||||
const text = sshDecoders.get(id)?.write(data) ?? data.toString('utf8')
|
||||
if (!text) return // the chunk was entirely a partial multi-byte char
|
||||
appendReplay(id, text)
|
||||
safeLog(id, text)
|
||||
deps.broadcast(Ipc.PTY_DATA, { id, data: text })
|
||||
@@ -282,6 +303,11 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
||||
feedZmodem(handle.id, data)
|
||||
})
|
||||
|
||||
handle.stream.on('exit', (code: number | undefined) => {
|
||||
// ssh2 reports the remote command's real exit status here, before 'close'.
|
||||
if (typeof code === 'number') handle.exitCode = code
|
||||
})
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
try {
|
||||
stopPolling(handle.id)
|
||||
@@ -290,7 +316,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
||||
safeStopLog(handle.id)
|
||||
sessions.delete(handle.id)
|
||||
replayBuffers.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
|
||||
sshDecoders.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: handle.exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
@@ -325,12 +352,13 @@ export function resizePty(id: string, cols: number, rows: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function killPty(id: string): void {
|
||||
function killSession(id: string): void {
|
||||
stopPolling(id)
|
||||
closeSftp(id)
|
||||
detachZmodem(id)
|
||||
safeStopLog(id)
|
||||
replayBuffers.delete(id)
|
||||
sshDecoders.delete(id)
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
if (session.kind === 'ssh') {
|
||||
@@ -354,6 +382,20 @@ export function killPty(id: string): void {
|
||||
sessions.delete(id)
|
||||
}
|
||||
|
||||
export function killPty(id: string): void {
|
||||
killSession(id)
|
||||
}
|
||||
|
||||
/** Kill every session owned by a renderer that crashed or was destroyed
|
||||
* without running its own cleanup — normal close/reload kills its own
|
||||
* sessions via beforeunload before we ever get here. */
|
||||
export function killPtysByOwner(owner: number): void {
|
||||
for (const id of [...sessions.keys()]) {
|
||||
if (sessions.get(id)?.owner !== owner) continue
|
||||
killSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const id of sessions.keys()) {
|
||||
stopPolling(id)
|
||||
@@ -382,4 +424,5 @@ export function killAllPtys(): void {
|
||||
}
|
||||
}
|
||||
sessions.clear()
|
||||
sshDecoders.clear()
|
||||
}
|
||||
+75
-17
@@ -10,7 +10,7 @@ import {
|
||||
type SystemSettings,
|
||||
type TerminalSettings
|
||||
} from '../shared/settings'
|
||||
import type { TerminalTheme } from '../shared/theme'
|
||||
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
|
||||
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
|
||||
import { broadcast } from './broadcast'
|
||||
import { applyGlobalShortcut } from './globalShortcuts'
|
||||
@@ -18,16 +18,12 @@ import { applyWindowChrome } from './windowChrome'
|
||||
|
||||
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
|
||||
|
||||
const DEFAULT_SYSTEM: SystemSettings = {
|
||||
launchAtLogin: false,
|
||||
preventSleep: false,
|
||||
globalShowHide: '',
|
||||
// Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made
|
||||
// a fresh install prompt on close while the docs and UI promised tray.
|
||||
closeAction: 'tray',
|
||||
autoCheckUpdate: true,
|
||||
language: DEFAULT_LANGUAGE
|
||||
}
|
||||
/**
|
||||
* Snapshot of the shared system defaults, derived rather than retyped so the two
|
||||
* cannot drift (a hand-written copy once shipped closeAction 'ask', which made a
|
||||
* fresh install prompt on close while the docs and UI promised the tray).
|
||||
*/
|
||||
const DEFAULT_SYSTEM: SystemSettings = { ...DEFAULT_SETTINGS.system }
|
||||
|
||||
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
|
||||
|
||||
@@ -108,6 +104,58 @@ function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
|
||||
return rules
|
||||
}
|
||||
|
||||
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
|
||||
|
||||
/**
|
||||
* Validate custom themes, repairing colours instead of dropping the theme.
|
||||
*
|
||||
* These reach `getThemeById`, which does a bare `.find()` over the list and
|
||||
* hands the result to xterm *and* to the window/title-bar colours — an entry
|
||||
* without an id or without colours used to throw inside `whenReady`, i.e. before
|
||||
* the tray and updater were wired, leaving a windowless process holding the
|
||||
* single-instance lock. Every colour missing from the stored entry is filled
|
||||
* from the built-in dark theme so a theme is always complete.
|
||||
*/
|
||||
function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const themes: TerminalTheme[] = []
|
||||
value.forEach((entry, index) => {
|
||||
if (entry === null || typeof entry !== 'object') {
|
||||
warnings.push(`customThemes[${index}]: not an object — skipped`)
|
||||
return
|
||||
}
|
||||
const theme = entry as Record<string, unknown>
|
||||
if (typeof theme.id !== 'string' || theme.id === '' || typeof theme.name !== 'string') {
|
||||
warnings.push(`customThemes[${index}]: missing id/name — skipped`)
|
||||
return
|
||||
}
|
||||
if (theme.colors === null || typeof theme.colors !== 'object') {
|
||||
warnings.push(`customThemes[${index}].colors repaired → built-in dark palette`)
|
||||
}
|
||||
const candidate =
|
||||
theme.colors !== null && typeof theme.colors === 'object'
|
||||
? (theme.colors as Record<string, unknown>)
|
||||
: null
|
||||
const colors: ThemeColors = { ...DEFAULT_DARK.colors }
|
||||
const target = colors as unknown as Record<string, string>
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const color = candidate?.[key]
|
||||
if (typeof color === 'string' && color !== '') target[key] = color
|
||||
else if (color !== undefined) {
|
||||
warnings.push(`customThemes[${index}].colors.${key} repaired → built-in default`)
|
||||
}
|
||||
}
|
||||
themes.push({
|
||||
...theme,
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
builtin: theme.builtin === true,
|
||||
colors
|
||||
} as TerminalTheme)
|
||||
})
|
||||
return themes
|
||||
}
|
||||
|
||||
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
||||
@@ -137,7 +185,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
}
|
||||
}
|
||||
|
||||
let system: unknown = DEFAULT_SYSTEM
|
||||
let system: unknown = { ...DEFAULT_SYSTEM }
|
||||
if (raw !== null && typeof raw === 'object') {
|
||||
const sys = (raw as { system?: unknown }).system
|
||||
if (sys !== null && typeof sys === 'object') {
|
||||
@@ -164,7 +212,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
}
|
||||
}
|
||||
|
||||
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
|
||||
const themes = sanitizeThemes(customThemes, errors)
|
||||
|
||||
return {
|
||||
settings: {
|
||||
@@ -306,13 +354,23 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
|
||||
return run
|
||||
}
|
||||
|
||||
/** Replace the persisted settings with `next` (serialized). */
|
||||
export function saveSettings(next: AppSettings): Promise<AppSettings> {
|
||||
return mutateSettings((current) => ({ ...current, ...next }))
|
||||
/** Merge a (partial) settings patch into the persisted settings. */
|
||||
export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
|
||||
// system/terminal merge one level deep, so a partial patch cannot wipe sibling
|
||||
// keys. The renderer now sends a minimal patch, so *absent* fields no longer
|
||||
// win over the stored state; stale-but-sent fields still do, which is why
|
||||
// main-process writes go through mutateSettings on the freshly read state
|
||||
// instead of this path.
|
||||
return mutateSettings((current) => ({
|
||||
...current,
|
||||
...next,
|
||||
terminal: { ...current.terminal, ...next.terminal },
|
||||
system: { ...current.system, ...next.system }
|
||||
}))
|
||||
}
|
||||
|
||||
export function registerSettingsIpc(): void {
|
||||
migrateDefaultsOnce()
|
||||
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
|
||||
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
|
||||
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: Partial<AppSettings>) => saveSettings(next))
|
||||
}
|
||||
+40
-7
@@ -41,8 +41,14 @@ export function formatMode(mode: number): string {
|
||||
[0o040, 'r'], [0o020, 'w'], [0o010, 'x'],
|
||||
[0o004, 'r'], [0o002, 'w'], [0o001, 'x']
|
||||
]
|
||||
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-')).join('')
|
||||
return kind + perm
|
||||
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-'))
|
||||
// Special bits share the x columns: setuid/setgid → s/S, sticky → t/T
|
||||
// (uppercase when the corresponding execute bit is clear). The renderer's
|
||||
// permission editor round-trips these, so report them instead of dropping.
|
||||
if ((mode & 0o4000) !== 0) perm[2] = perm[2] === 'x' ? 's' : 'S'
|
||||
if ((mode & 0o2000) !== 0) perm[5] = perm[5] === 'x' ? 's' : 'S'
|
||||
if ((mode & 0o1000) !== 0) perm[8] = perm[8] === 'x' ? 't' : 'T'
|
||||
return kind + perm.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,12 +76,23 @@ async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
|
||||
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
|
||||
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
|
||||
})
|
||||
// ssh2.d.ts declares only the used surface; the wrapper is an EventEmitter
|
||||
;(sftp as SFTPWrapper & { on(event: 'error', cb: (err: Error) => void): void }).on('error', (err: Error) => {
|
||||
console.error(`[sftp] channel error sessionId=${sessionId}: ${err.message}`)
|
||||
if (sftpCache.get(sessionId) === sftp) evictSftp(sessionId)
|
||||
})
|
||||
sftpCache.set(sessionId, sftp)
|
||||
return sftp
|
||||
}
|
||||
|
||||
function evictSftp(sessionId: string): void {
|
||||
const sftp = sftpCache.get(sessionId)
|
||||
sftpCache.delete(sessionId)
|
||||
try {
|
||||
sftp?.end?.()
|
||||
} catch {
|
||||
// best effort — the channel may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +104,7 @@ function evictSftp(sessionId: string): void {
|
||||
function isTransportError(err: unknown): boolean {
|
||||
if (err instanceof SessionGoneError) return true
|
||||
const msg = (err as Error | undefined)?.message ?? ''
|
||||
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg)
|
||||
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
|
||||
}
|
||||
|
||||
/** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */
|
||||
@@ -193,7 +210,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
|
||||
failures.push(`${path}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
if (paths.length > 0 && failures.length === paths.length) {
|
||||
if (failures.length > 0) {
|
||||
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
|
||||
}
|
||||
})
|
||||
@@ -235,13 +252,25 @@ function execQuiet(sessionId: string, cmd: string): Promise<void> {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
stream.on('data', () => undefined)
|
||||
const timer = setTimeout(() => {
|
||||
stream.close()
|
||||
reject(new Error(t('main.sftp.commandTimeout')))
|
||||
}, 10_000)
|
||||
stream.on('data', (d: Buffer) => {
|
||||
stdout += d.toString('utf8')
|
||||
})
|
||||
stream.stderr?.on('data', (d: Buffer) => {
|
||||
stderr += d.toString('utf8')
|
||||
})
|
||||
stream.on('error', (e: Error) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
})
|
||||
stream.on('close', (code: number) => {
|
||||
if (code) reject(new Error(stderr || t('main.sftp.commandExitCode', { code })))
|
||||
clearTimeout(timer)
|
||||
if (code) reject(new Error(stderr || stdout || t('main.sftp.commandExitCode', { code })))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
@@ -299,7 +328,6 @@ export function uploadRemote(
|
||||
try {
|
||||
let pos = 0
|
||||
let lastEmit = 0
|
||||
const buf = Buffer.alloc(CHUNK)
|
||||
for (;;) {
|
||||
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
|
||||
if (firstError) throw firstError
|
||||
@@ -307,6 +335,9 @@ export function uploadRemote(
|
||||
await Promise.race(inflight)
|
||||
if (firstError) throw firstError
|
||||
}
|
||||
// per-iteration buffer: pipelined writes outlive the loop, and
|
||||
// ssh2 re-reads the overflow tail after the ACK arrives
|
||||
const buf = Buffer.allocUnsafe(CHUNK)
|
||||
const { bytesRead } = await localHandle.read(buf, 0, CHUNK, pos)
|
||||
if (bytesRead === 0) break
|
||||
const offset = pos
|
||||
@@ -408,6 +439,8 @@ export function downloadRemote(
|
||||
} catch (err) {
|
||||
await fsp.rm(local, { force: true })
|
||||
throw err
|
||||
} finally {
|
||||
await pVoid(cb => sftp.close(handle, cb)).catch(() => undefined)
|
||||
}
|
||||
if (transfer.cancelled) {
|
||||
await fsp.rm(local, { force: true })
|
||||
|
||||
+19
-3
@@ -28,6 +28,11 @@ export interface SshSessionHandle {
|
||||
client: Client
|
||||
/** open interactive shell channel (ClientChannel, a Duplex) */
|
||||
stream: ClientChannel
|
||||
/**
|
||||
* Exit code reported for the session: the channel's 'exit' event when the
|
||||
* remote sends one, 1 when the client errors out mid-session, else 0.
|
||||
*/
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export interface SshServiceDeps {
|
||||
@@ -80,6 +85,7 @@ export async function connectSsh(
|
||||
let settled = false
|
||||
let connectTimer: NodeJS.Timeout | undefined
|
||||
let verifierErr: string | undefined
|
||||
let sessionHandle: SshSessionHandle | undefined
|
||||
let resolvePromise!: (handle: SshSessionHandle) => void
|
||||
let rejectPromise!: (err: Error) => void
|
||||
|
||||
@@ -161,6 +167,9 @@ export async function connectSsh(
|
||||
return
|
||||
}
|
||||
// Session already established: surface as a session exit and clean up.
|
||||
// Record the failure code on the handle so the stream's later 'close'
|
||||
// (pty.ts) reports the same exit code instead of a bogus 0.
|
||||
if (sessionHandle) sessionHandle.exitCode = 1
|
||||
try {
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
||||
} catch {
|
||||
@@ -201,7 +210,8 @@ export async function connectSsh(
|
||||
} catch {
|
||||
// store write failure must not break the session
|
||||
}
|
||||
resolvePromise({ id: sessionId, client: handshake, stream: shell })
|
||||
sessionHandle = { id: sessionId, client: handshake, stream: shell, exitCode: 0 }
|
||||
resolvePromise(sessionHandle)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -215,8 +225,14 @@ export async function connectSsh(
|
||||
hostVerifier
|
||||
}
|
||||
|
||||
// Password auth
|
||||
const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password')
|
||||
// Password auth. A stored password is offered only when the bookmark is
|
||||
// configured for password auth: connectionsStore keeps password_enc when a
|
||||
// bookmark is switched to key/agent auth, and silently falling back to it
|
||||
// would authenticate a weaker method than the user chose. An explicitly
|
||||
// typed connect-time password (secretOverride) is always honoured.
|
||||
const password =
|
||||
secretOverride?.password ??
|
||||
(conn.auth === 'password' ? deps.connections.getSecret(conn, 'password') : undefined)
|
||||
if (password !== undefined) cfg.password = password
|
||||
|
||||
// Private key auth (keyPath takes precedence over stored keyContent)
|
||||
|
||||
+24
-1
@@ -35,12 +35,15 @@ export function readJson<T = unknown>(file: string): T | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Backing buffer for Atomics.wait below: the retry sleep must be blocking
|
||||
* because every caller writes synchronously. */
|
||||
const RENAME_RETRY_WAIT = new Int32Array(new SharedArrayBuffer(4))
|
||||
|
||||
export function writeJson(file: string, value: unknown): void {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
const tmp = tmpPath(file)
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
||||
renameSync(tmp, file)
|
||||
} catch (err) {
|
||||
// Clean up the temp file so a failed write does not leave litter behind.
|
||||
try {
|
||||
@@ -50,6 +53,26 @@ export function writeJson(file: string, value: unknown): void {
|
||||
}
|
||||
throw err
|
||||
}
|
||||
// On Windows the replacing rename fails transiently with EPERM/EBUSY while
|
||||
// an indexer or AV scanner holds the destination open; a short retry keeps
|
||||
// an otherwise good write from being thrown away.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
renameSync(tmp, file)
|
||||
return
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY')) {
|
||||
try {
|
||||
unlinkSync(tmp)
|
||||
} catch {
|
||||
/* nothing to clean */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
Atomics.wait(RENAME_RETRY_WAIT, 0, 0, 5 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for stores whose file lives under a directory. */
|
||||
|
||||
+31
-6
@@ -129,10 +129,26 @@ function pollOnce(id: string, state: PollState): void {
|
||||
return
|
||||
}
|
||||
let out = ''
|
||||
let done = false
|
||||
// A wedged remote command (e.g. df on a hung NFS mount) never closes the
|
||||
// stream; without this watchdog the poll loop freezes silently forever.
|
||||
const watchdog = setTimeout(() => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
handleError(id, state, t('main.sysinfo.pollTimeout'))
|
||||
}, state.intervalMs * 2)
|
||||
stream.on('data', (d: Buffer) => {
|
||||
out += d.toString('utf8')
|
||||
})
|
||||
const onEnd = (): void => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(watchdog)
|
||||
if (state.stopped) return
|
||||
try {
|
||||
stream.close()
|
||||
@@ -142,7 +158,18 @@ function pollOnce(id: string, state: PollState): void {
|
||||
handleOutput(id, state, out)
|
||||
}
|
||||
stream.on('close', onEnd)
|
||||
stream.on('error', () => onEnd())
|
||||
stream.on('error', (streamErr: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(watchdog)
|
||||
if (state.stopped) return
|
||||
try {
|
||||
stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
handleError(id, state, streamErr?.message || t('main.sysinfo.execFailed'))
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(id, state, (err as Error).message)
|
||||
@@ -304,8 +331,8 @@ function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number;
|
||||
const irq = f[5] ?? 0
|
||||
const softirq = f[6] ?? 0
|
||||
const steal = f[7] ?? 0
|
||||
const guest = f[8] ?? 0
|
||||
const guestNice = f[9] ?? 0
|
||||
// guest/guest_nice (f[8]/f[9]) are already included in user/nice per
|
||||
// proc(5) — summing them in would double-count and understate CPU%.
|
||||
idle = idleTicks + iowait
|
||||
total =
|
||||
user +
|
||||
@@ -314,9 +341,7 @@ function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number;
|
||||
idle +
|
||||
irq +
|
||||
softirq +
|
||||
steal +
|
||||
guest +
|
||||
guestNice
|
||||
steal
|
||||
} else {
|
||||
cores += 1
|
||||
}
|
||||
|
||||
+8
-2
@@ -67,10 +67,12 @@ function wireEvents(): void {
|
||||
|
||||
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
|
||||
async function checkWithFallback(): Promise<void> {
|
||||
// The feed choice is per attempt: a transient Gitea failure must not pin
|
||||
// every later check to GitHub (and its system proxy) for the whole session.
|
||||
useFeed('gitea')
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
if (activeFeed !== 'gitea') throw err
|
||||
console.warn('[updater] gitea feed failed, falling back to github:', err)
|
||||
const giteaErr = err instanceof Error ? err.message : String(err)
|
||||
useFeed('github')
|
||||
@@ -164,11 +166,15 @@ export function registerUpdateIpc(): void {
|
||||
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
|
||||
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
|
||||
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
|
||||
if (!app.isPackaged) return
|
||||
// The close interceptor (tray flow) would swallow this quit — mark first.
|
||||
markQuitting()
|
||||
autoUpdater.quitAndInstall()
|
||||
// (isSilent, isForceRunAfter): relaunch the installed build, otherwise the
|
||||
// app would just disappear on "restart to update".
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
})
|
||||
ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog)
|
||||
ipcMain.handle(Ipc.UPDATE_STATE_GET, () => state)
|
||||
}
|
||||
|
||||
export function configureAutoUpdater(): void {
|
||||
|
||||
+26
-7
@@ -37,10 +37,12 @@ export interface ZmodemDeps {
|
||||
broadcast(channel: string, ...args: unknown[]): void
|
||||
/**
|
||||
* Forward NON-ZMODEM octets back through the normal terminal data path
|
||||
* (utf8 + replay + session log + PTY_DATA). pty.ts injects this; the engine
|
||||
* only calls it when no transfer is active.
|
||||
* (utf8 decode + replay + session log + PTY_DATA). pty.ts injects this and
|
||||
* owns the decoding (per-session StringDecoder — a multi-byte char may be
|
||||
* split across TCP chunks); the engine only calls it when no transfer is
|
||||
* active.
|
||||
*/
|
||||
toTerminal(sessionId: string, text: string): void
|
||||
toTerminal(sessionId: string, data: Buffer): void
|
||||
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
|
||||
writeStream(sessionId: string, data: Buffer): void
|
||||
}
|
||||
@@ -129,7 +131,17 @@ function finalize(
|
||||
}
|
||||
engine.receiveStream = null
|
||||
}
|
||||
engine.session = null // the zsession already ended; drop the reference
|
||||
if (!ok && engine.session) {
|
||||
// Failure paths (stall watchdog, corrupt frame) must stop the peer too:
|
||||
// without an abort the remote rz/sz keeps transmitting frames that then
|
||||
// leak into the terminal and the session log.
|
||||
try {
|
||||
engine.session.abort()
|
||||
} catch {
|
||||
// session already ended
|
||||
}
|
||||
}
|
||||
engine.session = null
|
||||
engine.detection = null
|
||||
|
||||
if (!engine.progressEmitted) {
|
||||
@@ -300,7 +312,7 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
// Analysis: also defensive against active http-parsing leftovers.
|
||||
if (engine.active) return // suppress binary terminal output during transfer
|
||||
try {
|
||||
deps.toTerminal(sessionId, Buffer.from(octets).toString('utf8'))
|
||||
deps.toTerminal(sessionId, Buffer.from(octets))
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
@@ -321,6 +333,8 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
engine.dir = null
|
||||
engine.session = null
|
||||
engine.receiveStream = null
|
||||
engine.bytes = 0
|
||||
engine.totalBytes = 0
|
||||
engine.transferId = `zm-${sessionId}`
|
||||
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
|
||||
try {
|
||||
@@ -328,7 +342,6 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
touchActivity(engine)
|
||||
engine.offerTimer = setTimeout(() => {
|
||||
if (engine.detection && !engine.confirmed) {
|
||||
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
|
||||
@@ -373,6 +386,12 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
* the CAN abort sequence directly so the remote program exits.
|
||||
*/
|
||||
function abortWithoutSession(engine: Engine, message: string): void {
|
||||
try {
|
||||
engine.detection?.deny()
|
||||
} catch {
|
||||
// already retracted or confirmed
|
||||
}
|
||||
engine.detection = null
|
||||
try {
|
||||
engine.deps.writeStream(engine.id, ABORT_BUFFER)
|
||||
} catch {
|
||||
@@ -425,7 +444,7 @@ export function isZmodemActive(sessionId: string): boolean {
|
||||
*/
|
||||
export function respondZmodem(resp: ZmodemResponse): void {
|
||||
const engine = current(resp.id)
|
||||
if (!engine || engine.confirmed) return
|
||||
if (!engine || !engine.active || engine.confirmed) return
|
||||
|
||||
if (resp.cancelled) {
|
||||
abortWithoutSession(engine, t('main.zmodem.cancelled'))
|
||||
|
||||
@@ -106,7 +106,7 @@ const api: AppApi = {
|
||||
ipcRenderer.send(Ipc.HOSTKEY_RESPOND, promptId, action),
|
||||
|
||||
getSettings: () => ipcRenderer.invoke(Ipc.SETTINGS_GET),
|
||||
saveSettings: (next: AppSettings) => ipcRenderer.invoke(Ipc.SETTINGS_SET, next),
|
||||
saveSettings: (next: Partial<AppSettings>) => ipcRenderer.invoke(Ipc.SETTINGS_SET, next),
|
||||
onSettingsChanged: (cb: (s: AppSettings) => void) => {
|
||||
const listener = (_: unknown, s: AppSettings): void => cb(s)
|
||||
ipcRenderer.on(Ipc.SETTINGS_CHANGED, listener)
|
||||
@@ -121,6 +121,7 @@ const api: AppApi = {
|
||||
deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id),
|
||||
|
||||
updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK),
|
||||
getUpdateState: () => ipcRenderer.invoke(Ipc.UPDATE_STATE_GET),
|
||||
updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD),
|
||||
updateInstall: () => ipcRenderer.invoke(Ipc.UPDATE_INSTALL),
|
||||
updateChangelog: () => ipcRenderer.invoke(Ipc.UPDATE_CHANGELOG),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog'
|
||||
import { TransferPanel } from '@renderer/sftp/TransferPanel'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import { DEFAULT_LANGUAGE, setLanguage, type Language } from '@shared/i18n'
|
||||
import { DEFAULT_LANGUAGE, syncLanguage, type Language } from '@shared/i18n'
|
||||
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
|
||||
import appIconUrl from '../../../build/icon.png'
|
||||
|
||||
@@ -42,16 +42,21 @@ export default function App(): React.JSX.Element {
|
||||
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
|
||||
const customThemes = useSettingsStore((s) => s.settings.customThemes)
|
||||
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
||||
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||
const storedLanguage = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
// Interface language: drives t() and antd's built-in component texts.
|
||||
// Interface language: t() reads the module-level language at render time, so
|
||||
// sync it before the tree renders — an effect would paint one frame late.
|
||||
// Silent on purpose: notifying subscribers during a render is what React
|
||||
// warns about; every language change re-renders App via the settings store.
|
||||
syncLanguage(storedLanguage)
|
||||
const language = storedLanguage
|
||||
useEffect(() => {
|
||||
setLanguage(language)
|
||||
document.documentElement.lang = language
|
||||
}, [language])
|
||||
|
||||
// Sidebar / tab bars / dividers follow the active terminal theme.
|
||||
|
||||
@@ -260,6 +260,9 @@ function Row({
|
||||
icon={<DeleteOutlined />}
|
||||
className="commands-row-del"
|
||||
aria-label={t('panels.commands.deleteAria')}
|
||||
// The row itself runs the command — opening the confirm must not
|
||||
// leak that click, or the command is typed into the live terminal.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
@@ -61,6 +61,11 @@ export function ConnectionEditDialog({
|
||||
setErrorMsg(null)
|
||||
const base: SshAuthMethod = editing?.auth ?? 'password'
|
||||
setAuth(base)
|
||||
// Fields keep their value when unmounted (rc-form preserve: true), so the
|
||||
// secrets below must be cleared explicitly — otherwise a password typed for
|
||||
// connection A survives into connection B's save. resetFields() drops every
|
||||
// stored value; setFieldsValue() then re-seeds the non-secret fields.
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
name: editing?.name,
|
||||
group: editing?.group,
|
||||
|
||||
@@ -85,13 +85,10 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
void refresh()
|
||||
}, [])
|
||||
|
||||
// Fold every group open whenever the dataset changes.
|
||||
// Fold every group open by default; only the user's collapses are stored,
|
||||
// so a group that appears later starts open as well.
|
||||
const defaultGroup = t('ssh.sidebar.defaultGroup')
|
||||
const activeGroups = useMemo(() => {
|
||||
const groups = new Set<string>()
|
||||
for (const c of connections) groups.add(c.group ?? defaultGroup)
|
||||
return [...groups]
|
||||
}, [connections, defaultGroup])
|
||||
const [closedGroups, setClosedGroups] = useState<string[]>([])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, SshConnection[]>()
|
||||
@@ -223,8 +220,10 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
ghost
|
||||
className="connections-collapse"
|
||||
items={collapseItems}
|
||||
defaultActiveKey={groups.map((g) => g.group)}
|
||||
activeKey={activeGroups}
|
||||
activeKey={groups.map((g) => g.group).filter((key) => !closedGroups.includes(key))}
|
||||
onChange={(keys) =>
|
||||
setClosedGroups(groups.map((g) => g.group).filter((key) => !keys.includes(key)))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { AppApi } from '../shared/api'
|
||||
import type { AppApi } from '@shared/api'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
@@ -81,7 +81,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
onHostKeyPrompt: () => noop,
|
||||
respondHostKey: noop,
|
||||
getSettings: async () => DEFAULT_SETTINGS,
|
||||
saveSettings: async (s) => s,
|
||||
saveSettings: async (s) => ({ ...DEFAULT_SETTINGS, ...s }),
|
||||
onSettingsChanged: () => noop,
|
||||
listFonts: async () => [],
|
||||
listLayouts: async () => [],
|
||||
@@ -89,6 +89,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
saveLayout: async () => undefined,
|
||||
deleteLayout: async () => undefined,
|
||||
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
||||
getUpdateState: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
||||
updateDownload: async () => undefined,
|
||||
updateInstall: async () => undefined,
|
||||
updateChangelog: async () => [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
|
||||
import { t } from '@shared/i18n'
|
||||
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
|
||||
import './monitor.css'
|
||||
|
||||
export interface MonitorPanelProps {
|
||||
@@ -25,8 +26,6 @@ interface HistoryChartProps {
|
||||
function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const dataRef = useRef<number[]>(data)
|
||||
dataRef.current = data
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -45,7 +44,7 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const pts = dataRef.current
|
||||
const pts = data
|
||||
if (pts.length < 2) return
|
||||
|
||||
const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2)
|
||||
@@ -81,7 +80,9 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
|
||||
const ro = new ResizeObserver(draw)
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
}, [height, maxY, lineColor, fillColor])
|
||||
// `data` is a dep on purpose: without it the chart only ever drew at mount,
|
||||
// when the history is still empty, and the sparklines never updated.
|
||||
}, [data, height, maxY, lineColor, fillColor])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mm-chart">
|
||||
@@ -134,8 +135,12 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
const [cpuHist, setCpuHist] = useState<number[]>([])
|
||||
const [rxHist, setRxHist] = useState<number[]>([])
|
||||
const [txHist, setTxHist] = useState<number[]>([])
|
||||
/** Switching workspace mode only hides the dockview (panels stay mounted), so
|
||||
* polling has to stop explicitly while the SSH workspace is not visible. */
|
||||
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== 'ssh') return
|
||||
let disposed = false
|
||||
setCpuHist([])
|
||||
setRxHist([])
|
||||
@@ -161,7 +166,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
offSample()
|
||||
window.api.sysinfoStop(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
}, [sessionId, workspaceMode])
|
||||
|
||||
if (!meta || !sample) {
|
||||
return (
|
||||
|
||||
@@ -74,6 +74,12 @@ export function AboutTab(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
|
||||
// Pull the updater's current state (no network check); the check itself
|
||||
// stays behind the manual button and the auto-check-on-launch setting.
|
||||
void window.api
|
||||
.getUpdateState()
|
||||
.then(setState)
|
||||
.catch(() => undefined)
|
||||
return window.api.onUpdateState(setState)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#
|
||||
/** clone the builtin preset set to prevent reference pollution by mutations. */
|
||||
const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[]
|
||||
|
||||
/**
|
||||
* Built-in rule notes are stored as zh-CN presets; the dictionaries carry a
|
||||
* `settings.highlight.builtin.<id>` entry per built-in id. `t()` returns the key
|
||||
* itself when it is missing, so fall back to the stored note in that case.
|
||||
*/
|
||||
function ruleNote(rule: HighlightRule): string | undefined {
|
||||
const key = `settings.highlight.builtin.${rule.id}`
|
||||
const translated = t(key)
|
||||
return translated === key ? rule.note : translated
|
||||
}
|
||||
|
||||
function tryCompile(pattern: string): { ok: boolean; message?: string } {
|
||||
if (pattern.trim() === '') return { ok: true }
|
||||
try {
|
||||
@@ -128,8 +139,10 @@ export function HighlightTab(): React.JSX.Element {
|
||||
title: t('settings.highlight.note'),
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (note: string | undefined) =>
|
||||
note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
|
||||
render: (_note: string | undefined, record) => {
|
||||
const note = ruleNote(record)
|
||||
return note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('settings.highlight.actions'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Modal, Tabs } from 'antd'
|
||||
import { Button, Modal, Popconfirm, Tabs } from 'antd'
|
||||
import type { TabsProps } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
@@ -11,6 +11,12 @@ import { AboutTab } from './AboutTab'
|
||||
import { ThemeEditor } from '../theme/editor/ThemeEditor'
|
||||
import './settings.css'
|
||||
|
||||
/** t() falls back to the key itself when a translation is missing. */
|
||||
function tOr(key: string, fallback: string): string {
|
||||
const value = t(key)
|
||||
return value === key ? fallback : value
|
||||
}
|
||||
|
||||
export interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -94,6 +100,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
void updateTerminal({ ...DEFAULT_SETTINGS.terminal })
|
||||
}
|
||||
|
||||
// Reset touches the whole terminal group, so it asks first and says so.
|
||||
const resetLabel = tOr('settings.resetTerminal', t('common.reset'))
|
||||
const resetDesc = tOr('settings.resetTerminalDesc', '')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -103,7 +113,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
title={t('settings.title')}
|
||||
footer={
|
||||
<div className="settings-footer">
|
||||
<Button onClick={handleResetDefaults}>{t('common.reset')}</Button>
|
||||
<Popconfirm
|
||||
title={tOr('settings.resetTerminalTitle', t('common.reset'))}
|
||||
description={resetDesc || undefined}
|
||||
okText={resetLabel}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={handleResetDefaults}
|
||||
>
|
||||
<Button>{resetLabel}</Button>
|
||||
</Popconfirm>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
|
||||
@@ -357,6 +357,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
export function SystemSettingsTab(): React.JSX.Element {
|
||||
const settings = useSettingsStore((s) => s.settings)
|
||||
const updateSystem = useSettingsStore((s) => s.updateSystem)
|
||||
/** set when the recorder refused a combo reserved by an in-app shortcut */
|
||||
const [shortcutConflict, setShortcutConflict] = useState(false)
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
{/* Interface language — first row: the whole dialog re-renders on change. */}
|
||||
@@ -433,11 +435,14 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
<ShortcutInput
|
||||
value={settings.system.globalShowHide ?? ''}
|
||||
onChange={(v) => void updateSystem({ globalShowHide: v })}
|
||||
onConflict={setShortcutConflict}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="settings-block-hint settings-shortcut-hint">
|
||||
{t('settings.system.globalShortcutHint')}
|
||||
{shortcutConflict
|
||||
? tOr('settings.system.shortcutConflict', t('settings.system.globalShortcutHint'))
|
||||
: t('settings.system.globalShortcutHint')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -486,12 +491,43 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx)
|
||||
* and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the
|
||||
* other modifiers, so any Control combo on one of these keys would shadow the
|
||||
* in-app action — the recorder refuses it instead of saving a shortcut that
|
||||
* silently loses its original meaning.
|
||||
*/
|
||||
const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown'])
|
||||
|
||||
/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */
|
||||
function isReservedAccelerator(accel: string): boolean {
|
||||
const parts = accel.split('+')
|
||||
return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
|
||||
}
|
||||
|
||||
/** t() falls back to the key itself when a translation is missing. */
|
||||
function tOr(key: string, fallback: string): string {
|
||||
const value = t(key)
|
||||
return value === key ? fallback : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Press-to-record input for the global shortcut: focus it, hit the combo, the
|
||||
* accelerator is captured and saved. Esc cancels recording, Backspace/Delete
|
||||
* clears (disables) the shortcut. Read-only so no stray text can be typed.
|
||||
* A combo reserved by an in-app shortcut is refused (and reported through
|
||||
* `onConflict`) without leaving the recording state, so the user can retry.
|
||||
*/
|
||||
function ShortcutInput({ value, onChange }: { value: string; onChange: (v: string) => void }): React.JSX.Element {
|
||||
function ShortcutInput({
|
||||
value,
|
||||
onChange,
|
||||
onConflict
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onConflict: (conflict: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const [recording, setRecording] = useState(false)
|
||||
return (
|
||||
<Input
|
||||
@@ -499,8 +535,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
|
||||
readOnly
|
||||
value={recording ? t('settings.system.shortcutRecording') : value}
|
||||
placeholder={t('settings.system.shortcutPlaceholder')}
|
||||
onFocus={() => setRecording(true)}
|
||||
onBlur={() => setRecording(false)}
|
||||
onFocus={() => {
|
||||
setRecording(true)
|
||||
onConflict(false)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setRecording(false)
|
||||
onConflict(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -514,10 +556,14 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
|
||||
return
|
||||
}
|
||||
const accel = acceleratorFromEvent(e)
|
||||
if (accel) {
|
||||
if (!accel) return
|
||||
if (isReservedAccelerator(accel)) {
|
||||
onConflict(true)
|
||||
return
|
||||
}
|
||||
onConflict(false)
|
||||
onChange(accel)
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,52 @@ let unsubscribeStoreListener: (() => void) | null = null
|
||||
/** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */
|
||||
let hydrating = false
|
||||
|
||||
/** Sub-keys of `prev` → `next` that actually changed, or undefined when none did. */
|
||||
function diffGroup<T extends object>(prev: T, next: T): Partial<T> | undefined {
|
||||
const patch: Partial<T> = {}
|
||||
for (const key of Object.keys(next) as (keyof T)[]) {
|
||||
if (prev[key] !== next[key]) patch[key] = next[key]
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? patch : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic write + persist. The SETTINGS_CHANGED echo re-sets the same value
|
||||
* (idempotent), so no rollback is needed for it. On failure, revert only while
|
||||
* the store still holds exactly what this call wrote: a newer call (e.g. a
|
||||
* ColorPicker drag) may already have saved a different value, and reverting
|
||||
* would drop it from the UI although it is on disk.
|
||||
*/
|
||||
async function persist(
|
||||
get: () => SettingsState,
|
||||
set: (partial: Partial<SettingsState>) => void,
|
||||
written: AppSettings,
|
||||
prev: AppSettings,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
set({ settings: written })
|
||||
try {
|
||||
// Send a minimal patch, not the full snapshot: main lets stale-but-sent
|
||||
// fields win, so a snapshot captured before a main-process mutateSettings
|
||||
// write (e.g. the tray's persistCloseAction) would silently revert it.
|
||||
// Absent fields are left untouched on disk.
|
||||
const patch: Partial<AppSettings> = {}
|
||||
if (written.customThemes !== prev.customThemes) patch.customThemes = written.customThemes
|
||||
if (written.highlightRules !== prev.highlightRules) patch.highlightRules = written.highlightRules
|
||||
const terminal = diffGroup(prev.terminal, written.terminal)
|
||||
// Main merges each group one level deep, so a group holding only the
|
||||
// changed sub-keys is a valid patch payload even though the contract types
|
||||
// groups as full objects.
|
||||
if (terminal) patch.terminal = terminal as TerminalSettings
|
||||
const system = diffGroup(prev.system, written.system)
|
||||
if (system) patch.system = system as SystemSettings
|
||||
await window.api.saveSettings(patch)
|
||||
} catch (err) {
|
||||
console.error(`[settings] ${label} failed, rolling back`, err)
|
||||
if (get().settings === written) set({ settings: prev })
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
hydrated: false,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
@@ -55,52 +101,25 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
...prev,
|
||||
terminal: { ...prev.terminal, ...partial }
|
||||
}
|
||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
||||
set({ settings: next })
|
||||
try {
|
||||
await window.api.saveSettings(next)
|
||||
} catch (err) {
|
||||
console.error('[settings] updateTerminal failed, rolling back', err)
|
||||
set({ settings: prev })
|
||||
}
|
||||
await persist(get, set, next, prev, 'updateTerminal')
|
||||
},
|
||||
|
||||
setCustomThemes: async (themes) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, customThemes: themes }
|
||||
set({ settings: next })
|
||||
try {
|
||||
await window.api.saveSettings(next)
|
||||
} catch (err) {
|
||||
console.error('[settings] setCustomThemes failed, rolling back', err)
|
||||
set({ settings: prev })
|
||||
}
|
||||
await persist(get, set, next, prev, 'setCustomThemes')
|
||||
},
|
||||
|
||||
setHighlightRules: async (rules) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, highlightRules: rules }
|
||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
||||
set({ settings: next })
|
||||
try {
|
||||
await window.api.saveSettings(next)
|
||||
} catch (err) {
|
||||
console.error('[settings] setHighlightRules failed, rolling back', err)
|
||||
set({ settings: prev })
|
||||
}
|
||||
await persist(get, set, next, prev, 'setHighlightRules')
|
||||
},
|
||||
|
||||
updateSystem: async (partial) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
|
||||
// optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent)
|
||||
set({ settings: next })
|
||||
try {
|
||||
await window.api.saveSettings(next)
|
||||
} catch (err) {
|
||||
console.error('[settings] updateSystem failed, rolling back', err)
|
||||
set({ settings: prev })
|
||||
}
|
||||
await persist(get, set, next, prev, 'updateSystem')
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -25,12 +25,32 @@ export interface FilePanelProps {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** 9 permission bits: [owner r,w,x, group r,w,x, other r,w,x]. */
|
||||
/**
|
||||
* Permission bits: [owner r,w,x, group r,w,x, other r,w,x, setuid, setgid, sticky].
|
||||
* The last three (indices 9..11) only exist as the 4th octal digit — the grid
|
||||
* does not toggle them, the octal input does.
|
||||
*/
|
||||
type PermBits = boolean[]
|
||||
|
||||
const MODE_LETTERS = ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x']
|
||||
/** x-column index → index of the special bit that changes its letter. */
|
||||
const SPECIAL_BIT_AT: Record<number, number> = { 2: 9, 5: 10, 8: 11 }
|
||||
|
||||
const emptyBits = (): PermBits => Array.from({ length: 12 }, () => false)
|
||||
|
||||
function parseModeBits(mode: string | undefined): PermBits {
|
||||
const chars = mode ? Array.from(mode.slice(1)) : []
|
||||
return Array.from({ length: 9 }, (_, i) => chars[i] != null && chars[i] !== '-')
|
||||
const bits = emptyBits()
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = chars[i]
|
||||
if (c == null || c === '-') continue
|
||||
// uppercase S/T means "special bit set, execute bit clear"
|
||||
bits[i] = c !== 'S' && c !== 'T'
|
||||
}
|
||||
if (chars[2] === 's' || chars[2] === 'S') bits[9] = true
|
||||
if (chars[5] === 's' || chars[5] === 'S') bits[10] = true
|
||||
if (chars[8] === 't' || chars[8] === 'T') bits[11] = true
|
||||
return bits
|
||||
}
|
||||
|
||||
function formatModeType(mode: string | undefined): string {
|
||||
@@ -39,9 +59,13 @@ function formatModeType(mode: string | undefined): string {
|
||||
}
|
||||
|
||||
function bitsToModeStr(type: string, bits: PermBits): string {
|
||||
const letters = bits.map((on, i) => {
|
||||
if (!on) return '-'
|
||||
return ['r', 'w', 'x', 'r', 'w', 'x', 'r', 'w', 'x'][i]
|
||||
const letters = MODE_LETTERS.map((on, i) => {
|
||||
const special = SPECIAL_BIT_AT[i]
|
||||
if (special !== undefined && bits[special]) {
|
||||
// setuid/setgid → s/S, sticky → t/T
|
||||
return bits[i] ? (i === 8 ? 't' : 's') : i === 8 ? 'T' : 'S'
|
||||
}
|
||||
return bits[i] ? on : '-'
|
||||
})
|
||||
return type + letters.join('')
|
||||
}
|
||||
@@ -56,7 +80,17 @@ function bitsToOctal(bits: PermBits): string {
|
||||
// arithmetic version computed 5*64+1*8+1 = 329 decimal, which is NOT the
|
||||
// octal "511" and made chmod a silent no-op when both sides agreed on the
|
||||
// same wrong value.)
|
||||
return `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}`
|
||||
const special = (bits[9] ? 4 : 0) + (bits[10] ? 2 : 0) + (bits[11] ? 1 : 0)
|
||||
const perms = `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}`
|
||||
// Keep the 3-digit form when no special bit is set so it round-trips with
|
||||
// what main sends (its mode string is 9 bits wide).
|
||||
return special > 0 ? `${special}${perms}` : perms
|
||||
}
|
||||
|
||||
/** t() falls back to the key itself when a translation is missing. */
|
||||
function tOr(key: string, fallback: string): string {
|
||||
const value = t(key)
|
||||
return value === key ? fallback : value
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
@@ -90,12 +124,14 @@ interface PermissionModalProps {
|
||||
|
||||
function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element {
|
||||
const { message } = App.useApp()
|
||||
const [bits, setBits] = useState<PermBits>(Array.from({ length: 9 }, () => false))
|
||||
const [bits, setBits] = useState<PermBits>(emptyBits)
|
||||
const [octal, setOctal] = useState('')
|
||||
const [uid, setUid] = useState('')
|
||||
const [gid, setGid] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
|
||||
/** lstat failed while listing → no mode to compare against or preserve. */
|
||||
const modeKnown = Boolean(entry?.mode)
|
||||
const rowLabels = permRowLabels()
|
||||
const colLabels = permColLabels()
|
||||
|
||||
@@ -110,11 +146,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
if (entry) {
|
||||
const b = parseModeBits(entry.mode)
|
||||
setBits(b)
|
||||
setOctal(bitsToOctal(b))
|
||||
setOctal(entry.mode ? bitsToOctal(b) : '')
|
||||
setUid(entry.uid != null ? String(entry.uid) : '')
|
||||
setGid(entry.gid != null ? String(entry.gid) : '')
|
||||
} else {
|
||||
setBits(Array.from({ length: 9 }, () => false))
|
||||
setBits(emptyBits())
|
||||
setOctal('')
|
||||
setUid('')
|
||||
setGid('')
|
||||
@@ -125,6 +161,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
const s = (v ?? '').trim()
|
||||
if (/^[0-7]{1,4}$/.test(s)) {
|
||||
const num = parseInt(s, 8) || 0
|
||||
const special = s.length === 4 ? parseInt(s[0], 8) : 0
|
||||
setBits([
|
||||
(num >> 6) & 4 ? true : false,
|
||||
(num >> 6) & 2 ? true : false,
|
||||
@@ -134,7 +171,10 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
(num >> 3) & 1 ? true : false,
|
||||
num & 4 ? true : false,
|
||||
num & 2 ? true : false,
|
||||
num & 1 ? true : false
|
||||
num & 1 ? true : false,
|
||||
(special & 4) !== 0,
|
||||
(special & 2) !== 0,
|
||||
(special & 1) !== 0
|
||||
])
|
||||
}
|
||||
setOctal(s)
|
||||
@@ -149,6 +189,15 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
})
|
||||
}
|
||||
|
||||
/** Non-negative integer input, '' meaning "keep current". */
|
||||
const parseId = (v: string): number | null => {
|
||||
const s = v.trim()
|
||||
if (s === '') return null
|
||||
const n = Number(s)
|
||||
return Number.isInteger(n) && n >= 0 ? n : null
|
||||
}
|
||||
const idInvalid = (v: string): boolean => v.trim() !== '' && parseId(v) === null
|
||||
|
||||
const onSave = (): void => {
|
||||
void (async () => {
|
||||
if (!entry) return
|
||||
@@ -156,23 +205,27 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
try {
|
||||
const octalStr = bitsToOctal(bits)
|
||||
const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null
|
||||
// Unknown mode (lstat failed): bits start all-false, so only chmod when
|
||||
// the user actually touched something — otherwise an untouched dialog
|
||||
// would send "chmod 000".
|
||||
const modeChanged = curOctal === null ? bits.some(Boolean) : octalStr !== curOctal
|
||||
// Only chown when the uid/gid actually differ from the entry's current
|
||||
// owner — the inputs are seeded with the current values, so "untouched"
|
||||
// would otherwise chown on every save.
|
||||
const newUid = uid.trim() !== '' && !Number.isNaN(Number(uid)) ? Number(uid) : null
|
||||
const newGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) ? Number(gid) : null
|
||||
const newUid = parseId(uid)
|
||||
const newGid = parseId(gid)
|
||||
const ownerChanged =
|
||||
(newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid)
|
||||
|
||||
if (octalStr !== curOctal) {
|
||||
if (modeChanged) {
|
||||
await window.api.chmodRemote(sessionId, entry.path, octalStr)
|
||||
}
|
||||
if (ownerChanged) {
|
||||
const u = newUid ?? entry.uid ?? 0
|
||||
const g = newGid ?? newUid ?? entry.gid ?? 0
|
||||
const g = newGid ?? entry.gid ?? 0
|
||||
await window.api.chownRemote(sessionId, entry.path, u, g)
|
||||
}
|
||||
if (octalStr !== curOctal || ownerChanged) {
|
||||
if (modeChanged || ownerChanged) {
|
||||
message.success(t('ssh.file.permApplied'))
|
||||
}
|
||||
onClose()
|
||||
@@ -194,6 +247,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
confirmLoading={saving}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }}
|
||||
width={380}
|
||||
>
|
||||
<div className="sftp-perm">
|
||||
@@ -253,6 +307,10 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
</div>
|
||||
</div>
|
||||
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
|
||||
{entry && !modeKnown && <div className="sftp-perm-hint">{t('ssh.file.modeUnavailable')}</div>}
|
||||
{(idInvalid(uid) || idInvalid(gid)) && (
|
||||
<div className="sftp-perm-hint">{t('main.sftp.invalidUidGid')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -302,18 +360,74 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
|
||||
const selectedPath = selected?.path ?? ''
|
||||
|
||||
const doUpload = (): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
const files = await window.api.pickFiles()
|
||||
if (files.length === 0) return
|
||||
await window.api.uploadRemote(sessionId, files, dir)
|
||||
// refresh when the transfer completes
|
||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
||||
if (e.state === 'done' || e.state === 'error' || e.state === 'cancelled') {
|
||||
/** local file name as main computes it for the remote path (basename). */
|
||||
const remoteNameOf = (local: string): string => local.split(/[\\/]/).pop() ?? local
|
||||
|
||||
/**
|
||||
* Start the upload. The listener is registered BEFORE the transfer id is
|
||||
* known: uploadRemote resolves with the id right away while the transfer
|
||||
* itself runs detached, so a small file can finish before the id arrives —
|
||||
* registering afterwards would miss the terminal event and never refresh.
|
||||
*/
|
||||
const startUpload = (files: string[]): void => {
|
||||
let targetId: string | null = null
|
||||
let settled = false
|
||||
const earlyTerminalIds: string[] = []
|
||||
const finish = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
off()
|
||||
void refresh(dir)
|
||||
}
|
||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
||||
if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return
|
||||
if (targetId === null) {
|
||||
earlyTerminalIds.push(e.transferId)
|
||||
return
|
||||
}
|
||||
if (e.transferId === targetId) finish()
|
||||
})
|
||||
window.api.uploadRemote(sessionId, files, dir).then(
|
||||
(id: string) => {
|
||||
targetId = id
|
||||
if (earlyTerminalIds.includes(id)) finish()
|
||||
},
|
||||
(err: unknown) => {
|
||||
off()
|
||||
message.error((err as Error).message)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const doUpload = (): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Explicit type: env.d.ts resolves `AppApi` through a wrong path, so
|
||||
// `window.api` is untyped and the callback params lose their context.
|
||||
const files: string[] = await window.api.pickFiles()
|
||||
if (files.length === 0) return
|
||||
// Main opens the target with 'w' (truncate), so an existing file would
|
||||
// be silently overwritten — ask first, like delete and clear-history do.
|
||||
const existing = files.map(remoteNameOf).filter((name) => entries.some((e) => e.name === name))
|
||||
if (existing.length === 0) {
|
||||
startUpload(files)
|
||||
return
|
||||
}
|
||||
const desc = tOr('ssh.file.overwriteDesc', '')
|
||||
modal.confirm({
|
||||
title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')),
|
||||
content: (
|
||||
<div>
|
||||
{existing.map((name) => (
|
||||
<div key={name}>{name}</div>
|
||||
))}
|
||||
{desc && <div>{desc}</div>}
|
||||
</div>
|
||||
),
|
||||
okText: t('common.ok'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => startUpload(files)
|
||||
})
|
||||
} catch (err) {
|
||||
message.error((err as Error).message)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import type { TransferKind, TransferProgressEvent } from '@shared/sftp'
|
||||
import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
interface TransferState {
|
||||
/** One aggregated transfer row in the overlay (the per-event state string is
|
||||
* the shared `TransferState`). */
|
||||
interface TransferEntry {
|
||||
kind: TransferKind
|
||||
file: string
|
||||
bytes: number
|
||||
totalBytes: number
|
||||
state: 'running' | 'done' | 'error' | 'cancelled'
|
||||
state: TransferState
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -17,13 +19,29 @@ interface TransferState {
|
||||
* transferId; done transfers fade out, errors stay until dismissed.
|
||||
*/
|
||||
export function TransferPanel(): React.JSX.Element | null {
|
||||
const [transfers, setTransfers] = useState<Map<string, TransferState>>(new Map())
|
||||
const timersRef = useRef<number[]>([])
|
||||
const [transfers, setTransfers] = useState<Map<string, TransferEntry>>(new Map())
|
||||
/** auto-dismiss timers, keyed by transfer id (never more than one per run) */
|
||||
const timersRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const dismiss = useCallback((id: string): void => {
|
||||
const timer = timersRef.current.get(id)
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timersRef.current.delete(id)
|
||||
}
|
||||
setTransfers((prev) => {
|
||||
if (!prev.has(id)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
||||
setTransfers((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(e.transferId)
|
||||
if (e.file) {
|
||||
next.set(e.transferId, {
|
||||
kind: e.kind,
|
||||
@@ -34,42 +52,51 @@ export function TransferPanel(): React.JSX.Element | null {
|
||||
error: e.error
|
||||
})
|
||||
} else {
|
||||
// terminal event for the whole transfer
|
||||
const existing = next.get(e.transferId)
|
||||
if (existing) {
|
||||
if (e.state === 'error') {
|
||||
next.set(e.transferId, { ...existing, state: 'error', error: e.error })
|
||||
} else if (e.state === 'cancelled') {
|
||||
next.set(e.transferId, { ...existing, state: 'cancelled' })
|
||||
} else {
|
||||
next.set(e.transferId, { ...existing, state: 'done' })
|
||||
// Terminal event for the whole transfer. It can arrive without any
|
||||
// per-file progress first (e.g. the remote open was denied), so a
|
||||
// missing entry is created rather than dropped — otherwise the
|
||||
// failure would be visible nowhere.
|
||||
const base: TransferEntry = existing ?? {
|
||||
kind: e.kind,
|
||||
file: '',
|
||||
bytes: e.bytes,
|
||||
totalBytes: e.totalBytes,
|
||||
state: 'running'
|
||||
}
|
||||
if (e.state === 'error') {
|
||||
next.set(e.transferId, { ...base, state: 'error', error: e.error })
|
||||
} else if (e.state === 'cancelled') {
|
||||
next.set(e.transferId, { ...base, state: 'cancelled' })
|
||||
} else {
|
||||
next.set(e.transferId, { ...base, state: 'done' })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
off()
|
||||
for (const t of timersRef.current) window.clearTimeout(t)
|
||||
}
|
||||
return off
|
||||
}, [])
|
||||
|
||||
// auto-dismiss finished entries after 3s
|
||||
useEffect(() => {
|
||||
for (const [id, t] of transfers) {
|
||||
if ((t.state === 'done' || t.state === 'cancelled') && !t.error) {
|
||||
for (const [id, tr] of transfers) {
|
||||
if ((tr.state === 'done' || tr.state === 'cancelled') && !tr.error && !timersRef.current.has(id)) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setTransfers((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
timersRef.current.delete(id)
|
||||
dismiss(id)
|
||||
}, 3000)
|
||||
timersRef.current.push(timer)
|
||||
timersRef.current.set(id, timer)
|
||||
}
|
||||
}
|
||||
}, [transfers])
|
||||
}, [transfers, dismiss])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) window.clearTimeout(timer)
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const list = [...transfers.entries()]
|
||||
if (list.length === 0) return null
|
||||
@@ -100,13 +127,7 @@ export function TransferPanel(): React.JSX.Element | null {
|
||||
<button
|
||||
type="button"
|
||||
className="sftp-transfer-close"
|
||||
onClick={() =>
|
||||
setTransfers((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
onClick={() => dismiss(id)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { CommandItem } from '@shared/commands'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
|
||||
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
|
||||
import { compileRules, HighlightStream } from './highlightEngine'
|
||||
import { findUrls } from './urlLinks'
|
||||
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
||||
import { cdArgument, conemuCwd } from './cwdTracker'
|
||||
@@ -139,39 +139,17 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
|
||||
* handled here; escape sequences (\x1b) and the leading non-printable control
|
||||
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer.
|
||||
*
|
||||
* `rewrite` flags a completion acceptance that just rewrote the shell line (a
|
||||
* Ctrl+U kill + full-command write). The very next onData chunk is the shell's
|
||||
* redraw echoing the accepted line, so instead of appending onto the stale
|
||||
* pre-accept buffer we *replace* it with what the pty actually echoed. This is
|
||||
* the echo-suppression that keeps the local buffer in sync with the shell and
|
||||
* prevents the accepted text from being duplicated.
|
||||
* readline's line-editing keys (Ctrl+U/W/K, Ctrl+A/E/L/Y, …) are handled too:
|
||||
* the shell applies them to its own line and never echoes the resulting edit
|
||||
* back to us, so appending the raw control byte would silently desync this
|
||||
* buffer from the shell's line and make the history record the pre-edit text.
|
||||
*/
|
||||
function stepLineBuffer(
|
||||
outBuf: React.MutableRefObject<string>,
|
||||
data: string,
|
||||
rewrite: boolean
|
||||
data: string
|
||||
): { line: string; submit?: string } {
|
||||
const b = outBuf.current
|
||||
if (!data) return { line: b }
|
||||
// Completion-rewrite redraw: reset to this chunk's visible text verbatim. For
|
||||
// a shell like PowerShell the redraw is a leading escape/control sequence
|
||||
// + the line text; strip leading control bytes so only the echoed command
|
||||
// enters the buffer (matching how a normal chunk is captured). Any trailing
|
||||
// text on the chunk is appended as usual.
|
||||
if (rewrite) {
|
||||
let i = 0
|
||||
while ((data[i] === '\x1b' && (data[i + 1] === '[' || data[i + 1] === '(')) || data[i] === '\r' || data[i] === '\n') {
|
||||
i += data[i] === '\x1b' ? 2 : 1
|
||||
}
|
||||
const visible = data.slice(i)
|
||||
if (visible) {
|
||||
outBuf.current = visible
|
||||
return { line: visible }
|
||||
}
|
||||
// Only control output (e.g. the kill sequence handling) — treat as a reset.
|
||||
outBuf.current = ''
|
||||
return { line: '' }
|
||||
}
|
||||
// xterm-native paste (plain Ctrl+V): with the shell's bracketed-paste mode
|
||||
// on, the pasted block arrives wrapped in \x1b[200~ / \x1b[201~ and the chunk
|
||||
// starts with ESC, which the guards below would drop wholesale. Strip the
|
||||
@@ -195,6 +173,21 @@ function stepLineBuffer(
|
||||
return { line: '' }
|
||||
}
|
||||
if (first === '\x1b') return { line: b }
|
||||
// Ctrl+U / Ctrl+K kill the shell's line (before / after the cursor); with no
|
||||
// cursor tracking the safe mirror of that is an empty buffer.
|
||||
if (first === '\x15' || first === '\x0b') {
|
||||
outBuf.current = ''
|
||||
return { line: '' }
|
||||
}
|
||||
// Ctrl+W: readline skips trailing whitespace and kills the word before it.
|
||||
if (first === '\x17') {
|
||||
outBuf.current = b.replace(/\S+\s*$/, '')
|
||||
return { line: outBuf.current }
|
||||
}
|
||||
// Any other C0 byte is a key the shell interprets itself (Ctrl+A/E/L/Y,
|
||||
// Ctrl+R, Ctrl+D, Tab, …) and adds no text to the line — the buffer must not
|
||||
// grow either.
|
||||
if (data.charCodeAt(0) < 0x20) return { line: b }
|
||||
outBuf.current = b + data
|
||||
return { line: outBuf.current }
|
||||
}
|
||||
@@ -273,17 +266,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
const searchTermRef = useRef('')
|
||||
const nativePasteRef = useRef<string | null>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
const compiledRef = useRef<CompiledRule[]>([])
|
||||
const streamRef = useRef<HighlightStream | null>(null)
|
||||
// Renderer mode currently installed on the terminal, so the mode effect can
|
||||
// tell a real switch from its own mount-time run.
|
||||
const rendererModeRef = useRef<TerminalSettings['rendererMode'] | null>(null)
|
||||
// M5: per-session line-capture buffer + completion candidate cache.
|
||||
const lineBufRef = useRef<string>('')
|
||||
// M5: when a completion acceptance rewrites the shell line (Ctrl+U + full
|
||||
// command via writeBroadcast in `differenceWrite`), the shell answers with a
|
||||
// redraw that echoes `full`. If that echo were folded into the local buffer
|
||||
// on top of the pre-accept text it would double the line. `diffRewriteRef`
|
||||
// flags that the *next* onData chunk is that redraw output, so stepLineBuffer
|
||||
// replaces the buffer with the freshly-echoed line instead of appending.
|
||||
const diffRewriteRef = useRef(false)
|
||||
const completionCacheRef = useRef<CommandItem[]>([])
|
||||
const completionKeyRef = useRef('')
|
||||
const recordingRef = useRef(false)
|
||||
@@ -302,7 +290,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// latest rules without triggering a terminal rebuild. Only new output is
|
||||
// highlighted — the existing scrollback buffer is left untouched.
|
||||
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
|
||||
compiledRef.current = compiledRules
|
||||
streamRef.current?.setRules(compiledRules)
|
||||
|
||||
// M5: load completion candidates once per session (history + library cached).
|
||||
@@ -352,7 +339,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
webglRef.current.dispose()
|
||||
webglRef.current = null
|
||||
}
|
||||
if (mode === 'dom') return
|
||||
if (mode === 'dom') {
|
||||
rendererModeRef.current = mode
|
||||
return
|
||||
}
|
||||
try {
|
||||
const webgl = new WebglAddon()
|
||||
webgl.onContextLoss(() => {
|
||||
@@ -366,6 +356,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
} catch {
|
||||
webglRef.current = null
|
||||
}
|
||||
rendererModeRef.current = mode
|
||||
}, [])
|
||||
|
||||
const scheduleFit = useCallback(() => {
|
||||
@@ -386,6 +377,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
const send = (): void => {
|
||||
const live = termRef.current
|
||||
if (!live || deadRef.current) return
|
||||
// A pane without a layout box (hidden or not-yet-laid-out dock tab)
|
||||
// reports a degenerate grid; that — and any other sub-minimum size —
|
||||
// must never reach the pty, which would reflow the shell into a
|
||||
// one-column line. Skipping leaves ptySizeRef untouched, so the next
|
||||
// fit retries instead of remembering the bad size.
|
||||
if (hostRef.current?.offsetParent === null) return
|
||||
if (live.cols < 20 || live.rows < 4) return
|
||||
if (live.cols === ptySizeRef.current?.cols && live.rows === ptySizeRef.current?.rows) return
|
||||
ptySizeRef.current = { cols: live.cols, rows: live.rows }
|
||||
window.api.resizePty(sessionId, live.cols, live.rows)
|
||||
@@ -555,8 +553,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
} catch {
|
||||
/* clipboard write denied */
|
||||
}
|
||||
if (!settings.terminal.copyOnSelect) term.clearSelection()
|
||||
}, [settings.terminal.copyOnSelect])
|
||||
// Read the setting at call time: this runs from the terminal's custom key
|
||||
// handler, which is attached once per session and would otherwise keep the
|
||||
// copyOnSelect value captured at mount.
|
||||
if (!useSettingsStore.getState().settings.terminal.copyOnSelect) term.clearSelection()
|
||||
}, [])
|
||||
|
||||
const pasteFromClipboard = useCallback(async () => {
|
||||
try {
|
||||
@@ -573,11 +574,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
(selected: CommandItem): void => {
|
||||
const cmd = selected.command
|
||||
// Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a
|
||||
// delta — the pty redraw, not our local buffer, becomes the source of
|
||||
// truth for the line (see differenceWrite). The redraw trigger sets the
|
||||
// rewrite flag so onData resets the line buffer to the echoed line.
|
||||
// delta, so the pty never has to reconcile our local buffer with its own
|
||||
// (see differenceWrite). The local buffer mirrors the rewrite — the shell
|
||||
// does not echo it back through onData, so nothing else would update it,
|
||||
// and the next Enter would otherwise record the pre-accept text.
|
||||
differenceWrite(sessionId, cmd, lineBufRef.current)
|
||||
diffRewriteRef.current = true
|
||||
lineBufRef.current = cmd
|
||||
suggestionsRef.current = []
|
||||
selIndexRef.current = 0
|
||||
@@ -792,15 +793,27 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
rows.push({ index: row, text: line.translateToString(false) })
|
||||
}
|
||||
const text = rows.map((r) => r.text).join('')
|
||||
// translateToString emits one char per cell run — a double-width char
|
||||
// is ONE char spanning TWO columns — so a char offset cannot be mapped
|
||||
// to a column with `% cols` (every CJK char on the line shifts the
|
||||
// link range). Walk the cells once and record the 1-based x / buffer
|
||||
// row of each char position in `text`.
|
||||
const cols = term.cols > 0 ? term.cols : 1
|
||||
const base = rows[0]?.index ?? first
|
||||
const colAt: number[] = []
|
||||
const rowAt: number[] = []
|
||||
for (const { index: row } of rows) {
|
||||
const line = buffer.getLine(row)
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if ((line?.getCell(col)?.getWidth() ?? 1) === 0) continue // wide-char tail
|
||||
colAt.push(col + 1)
|
||||
rowAt.push(row)
|
||||
}
|
||||
}
|
||||
const links: ILink[] = findUrls(text).map((found) => {
|
||||
const startRow = Math.floor(found.start / cols)
|
||||
const endRow = Math.floor((found.end - 1) / cols)
|
||||
return {
|
||||
range: {
|
||||
start: { x: (found.start % cols) + 1, y: base + startRow + 1 },
|
||||
end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 }
|
||||
start: { x: colAt[found.start] ?? 1, y: (rowAt[found.start] ?? first) + 1 },
|
||||
end: { x: colAt[found.end - 1] ?? 1, y: (rowAt[found.end - 1] ?? first) + 1 }
|
||||
},
|
||||
text: found.url,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
@@ -822,10 +835,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
linkProvider,
|
||||
term.onData((data) => {
|
||||
if (!deadRef.current) writeBroadcast(sessionId, data)
|
||||
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
|
||||
// now so this redraw chunk replaces (not appends to) the line buffer.
|
||||
const consumeRewrite = diffRewriteRef.current
|
||||
diffRewriteRef.current = false
|
||||
// Full-screen TUIs (vim, Claude Code, …) own the alternate buffer, their
|
||||
// own key handling and their own cursor: the line buffer, the command
|
||||
// history and the completion popup have no business there. Interfering
|
||||
@@ -841,8 +850,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
}
|
||||
return
|
||||
}
|
||||
// M5: line capture → recordCommand + inline completion overlay.
|
||||
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
|
||||
// M5: line capture → recordCommand + inline completion overlay. Only user
|
||||
// input reaches onData; the shell's own redraws arrive on the pty path,
|
||||
// which never touches the buffer.
|
||||
const step = stepLineBuffer(lineBufRef, data)
|
||||
if (typeof step.submit === 'string' && step.submit) {
|
||||
trackSubmittedLine(step.submit)
|
||||
}
|
||||
@@ -1005,6 +1016,9 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// ---- renderer mode changes: keep the instance, swap renderer, refit ----
|
||||
useEffect(() => {
|
||||
const next = tSettings.rendererMode
|
||||
// The construction effect above already applied the mode on mount (this
|
||||
// effect runs on mount too); only an actual switch should re-run it.
|
||||
if (next === rendererModeRef.current) return
|
||||
if (next === 'dom') {
|
||||
applyRenderers('dom')
|
||||
} else if (!webglRef.current) {
|
||||
|
||||
@@ -33,6 +33,16 @@ const ESCAPE_RE =
|
||||
const MAX_PER_RULE = 300
|
||||
/** Chunks larger than this are returned unmodified (budget guard). */
|
||||
const MAX_CHUNK = 512 * 1024
|
||||
/**
|
||||
* Longest newline-free segment of a plain-text run a user rule is applied to.
|
||||
* MAX_PER_RULE bounds the match *count*, not a pattern's backtracking *cost*:
|
||||
* a pathological pattern like `(a+)+b` walks the whole line before the count
|
||||
* can stop it, which freezes the renderer on one very long line. A run holding
|
||||
* such a line is passed through unhighlighted — ordinary output (many short
|
||||
* lines, whatever the chunk size) is unaffected, since the bound is per line,
|
||||
* not per run.
|
||||
*/
|
||||
const MAX_LINE_LEN = 4 * 1024
|
||||
|
||||
/** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
@@ -107,14 +117,26 @@ function overlaps(claimed: Span[], s: number, e: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
|
||||
function hasLongLine(text: string): boolean {
|
||||
let start = 0
|
||||
for (;;) {
|
||||
const nl = text.indexOf('\n', start)
|
||||
if (nl < 0) return text.length - start > MAX_LINE_LEN
|
||||
if (nl - start > MAX_LINE_LEN) return true
|
||||
start = nl + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule
|
||||
* remaining match allowance shared across the whole chunk). Returns the
|
||||
* SGR-injected text. With a global budget of zero for every rule the run is
|
||||
* returned untouched.
|
||||
* returned untouched, as is a run holding an over-long line (see MAX_LINE_LEN).
|
||||
*/
|
||||
function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string {
|
||||
if (text.length === 0) return ''
|
||||
if (hasLongLine(text)) return text
|
||||
const claimed: Span[] = []
|
||||
for (let ri = 0; ri < rules.length; ri++) {
|
||||
const rule = rules[ri]
|
||||
|
||||
@@ -22,6 +22,14 @@ function toHex(v: number): string {
|
||||
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio between two colors; 21 when either is not hex. */
|
||||
function contrast(a: string, b: string): number {
|
||||
const la = luminance(a)
|
||||
const lb = luminance(b)
|
||||
if (la === null || lb === null) return 21
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
/** t=0 → a, t=1 → b */
|
||||
export function mix(a: string, b: string, t: number): string | null {
|
||||
const ca = parseHex(a)
|
||||
@@ -34,6 +42,18 @@ export function mix(a: string, b: string, t: number): string | null {
|
||||
* from it flip their overlay direction (white-on-dark vs black-on-light). */
|
||||
let chromeIsLight = false
|
||||
|
||||
/** Dark-chrome tab label: the background mixed toward the theme's own foreground
|
||||
* (the light branch is the same mix toward black), but never below 4.5:1 against
|
||||
* the background, since a theme foreground dimmed by 0.68 can land unreadable. */
|
||||
function tabLabel(background: string, foreground: string, t: number): string {
|
||||
const mixed = mix(background, foreground, t)
|
||||
return mixed && contrast(mixed, background) >= 4.5 ? mixed : foreground
|
||||
}
|
||||
|
||||
/** Accent color from the last applyTabAccent() call; re-applied at the end of a
|
||||
* chrome theme change so --tab-accent-fg follows the new theme's luminance. */
|
||||
let tabAccent = ''
|
||||
|
||||
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
|
||||
* terminal theme so the whole window follows theme switches instead of only
|
||||
* the terminal canvas. Values land on :root CSS variables; anything the theme
|
||||
@@ -67,9 +87,10 @@ export function applyChromeTheme(colors: ThemeColors): void {
|
||||
set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)')
|
||||
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
|
||||
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
|
||||
set('--chrome-tab-fg', '#98a2ab')
|
||||
set('--chrome-tab-fg-hover', '#d0d0d0')
|
||||
set('--chrome-tab-fg', tabLabel(colors.background, colors.foreground, 0.68))
|
||||
set('--chrome-tab-fg-hover', tabLabel(colors.background, colors.foreground, 0.88))
|
||||
}
|
||||
applyTabAccent(tabAccent)
|
||||
}
|
||||
|
||||
/** Tab accent: base color + a foreground variant that stays readable on the
|
||||
@@ -77,6 +98,7 @@ export function applyChromeTheme(colors: ThemeColors): void {
|
||||
* var(--tab-accent*) in workspace.css. */
|
||||
export function applyTabAccent(color: string): void {
|
||||
const style = document.documentElement.style
|
||||
tabAccent = color
|
||||
// Settings hydrated from an older main process may lack the key; clear the
|
||||
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
|
||||
if (!color) {
|
||||
|
||||
@@ -15,49 +15,79 @@ const MIN_HEIGHT = 120
|
||||
export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
|
||||
/** Current file-browser height in px. */
|
||||
const [filesHeight, setFilesHeight] = useState<number>(220)
|
||||
/** Container height captured while a drag is in flight. */
|
||||
const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null)
|
||||
/** Divider drag in flight — drives the accent highlight. */
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
/** Drag origin; non-null only while a divider drag is in flight. */
|
||||
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||
/** Live container height, refreshed by the observer below: clamping against a
|
||||
* height captured at mousedown keeps a stale maximum after a window resize. */
|
||||
const containerHRef = useRef(0)
|
||||
|
||||
const onDividerMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (e.button !== 0) return
|
||||
const container = (e.currentTarget.parentElement as HTMLElement | null) ?? null
|
||||
if (!container) return
|
||||
dragInfoRef.current = {
|
||||
startY: e.clientY,
|
||||
startHeight: filesHeight,
|
||||
containerH: container.clientHeight
|
||||
}
|
||||
e.preventDefault()
|
||||
}, [filesHeight])
|
||||
const clampHeight = useCallback((height: number): number => {
|
||||
return Math.max(MIN_HEIGHT, Math.min(Math.round(containerHRef.current * 0.6), height))
|
||||
}, [])
|
||||
|
||||
// Re-clamp on container resize: a shrunk window would otherwise leave the file
|
||||
// browser taller than 60% of the panel, collapsing the terminal.
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent): void => {
|
||||
const info = dragInfoRef.current
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
containerHRef.current = root.clientHeight
|
||||
setFilesHeight((prev) => clampHeight(prev))
|
||||
})
|
||||
observer.observe(root)
|
||||
return () => observer.disconnect()
|
||||
}, [clampHeight])
|
||||
|
||||
const onDividerPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (e.button !== 0) return
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
containerHRef.current = root.clientHeight
|
||||
dragRef.current = { startY: e.clientY, startHeight: filesHeight }
|
||||
// Pointer capture keeps move/up events coming even when the pointer leaves
|
||||
// the window, so a release outside it cannot strand the drag.
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
setResizing(true)
|
||||
e.preventDefault()
|
||||
},
|
||||
[filesHeight]
|
||||
)
|
||||
|
||||
const onDividerPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const info = dragRef.current
|
||||
if (!info) return
|
||||
const delta = e.clientY - info.startY
|
||||
const max = Math.round(info.containerH * 0.6)
|
||||
setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta)))
|
||||
}
|
||||
const onUp = (): void => {
|
||||
dragInfoRef.current = null
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
setFilesHeight(clampHeight(info.startHeight - (e.clientY - info.startY)))
|
||||
},
|
||||
[clampHeight]
|
||||
)
|
||||
|
||||
const endDrag = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (!dragRef.current) return
|
||||
dragRef.current = null
|
||||
setResizing(false)
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="ssh-bottom-panel">
|
||||
<div className="ssh-bottom-panel" ref={rootRef}>
|
||||
<div className="ssh-bottom-top">{terminal}</div>
|
||||
<div
|
||||
className="ssh-bottom-divider"
|
||||
className={'ssh-bottom-divider' + (resizing ? ' is-resizing' : '')}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('ssh.bottomPanel.resize')}
|
||||
onMouseDown={onDividerMouseDown}
|
||||
onPointerDown={onDividerPointerDown}
|
||||
onPointerMove={onDividerPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onLostPointerCapture={endDrag}
|
||||
/>
|
||||
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
|
||||
<FilePanel sessionId={sessionId} />
|
||||
|
||||
@@ -42,17 +42,23 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
|
||||
const [sideOpen, setSideOpen] = useState(true)
|
||||
|
||||
// M6 broadcast: keep the session in the broadcast registry while mounted so
|
||||
// it can be picked as a target / receive fan-out writes (idempotent upsert).
|
||||
// it can be picked as a target / receive fan-out writes. Registrations are
|
||||
// keyed by panel id — an SSH split shows one session in two panes, and
|
||||
// unmounting one of them must not unregister the other.
|
||||
useEffect(() => {
|
||||
useBroadcastStore.getState().registerSession({ id: sessionId, title: api.title ?? '', isSsh })
|
||||
return () => useBroadcastStore.getState().unregisterSession(sessionId)
|
||||
useBroadcastStore
|
||||
.getState()
|
||||
.registerSession({ id: sessionId, panelId: api.id, title: api.title ?? '', isSsh })
|
||||
return () => useBroadcastStore.getState().unregisterSession(api.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below
|
||||
}, [sessionId])
|
||||
|
||||
// M6: keep the registered broadcast title in sync with the tab title.
|
||||
useEffect(() => {
|
||||
const disposable = api.onDidTitleChange((event) => {
|
||||
useBroadcastStore.getState().registerSession({ id: sessionId, title: event.title, isSsh })
|
||||
useBroadcastStore
|
||||
.getState()
|
||||
.registerSession({ id: sessionId, panelId: api.id, title: event.title, isSsh })
|
||||
})
|
||||
return () => disposable.dispose()
|
||||
}, [api, sessionId, isSsh])
|
||||
|
||||
@@ -63,12 +63,15 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
|
||||
if (key === 'close') {
|
||||
api.close()
|
||||
} else if (key === 'closeOthers') {
|
||||
for (const sibling of panel.panels) {
|
||||
// `panel.panels` is the live group array and `close()` splices it, so
|
||||
// the targets must be snapshotted first — otherwise every element that
|
||||
// shifts into the current index is skipped.
|
||||
for (const sibling of [...panel.panels]) {
|
||||
if (sibling.api !== api) closePanel(sibling)
|
||||
}
|
||||
} else if (key === 'closeRight') {
|
||||
for (let i = selfIndex + 1; i < panel.panels.length; i++) {
|
||||
closePanel(panel.panels[i])
|
||||
for (const target of [...panel.panels].slice(selfIndex + 1)) {
|
||||
closePanel(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
DockviewApi,
|
||||
DockviewPanelApi,
|
||||
DockviewReadyEvent,
|
||||
DockviewTheme,
|
||||
IDockviewHeaderActionsProps,
|
||||
IDockviewPanel,
|
||||
IDockviewPanelHeaderProps,
|
||||
@@ -61,6 +62,54 @@ export type { TerminalParams }
|
||||
const TERMINAL_COMPONENT = 'terminal'
|
||||
const TERMINAL_TAB_COMPONENT = 'terminal-tab'
|
||||
|
||||
/**
|
||||
* Sessions we already asked to kill — kill once, never twice.
|
||||
*
|
||||
* Module-level rather than a ref so the dockview `components` /
|
||||
* `tabComponents` maps below can be module constants: dockview re-runs its prop
|
||||
* effects on every identity change and each `updateOptions` ends in an
|
||||
* unconditional `_layoutFromShell()`.
|
||||
*/
|
||||
const deadSessions = new Set<string>()
|
||||
|
||||
/** Mark a session dead ahead of a panel close so the cleanup skips it. */
|
||||
function markSessionDead(sessionId: string): void {
|
||||
deadSessions.add(sessionId)
|
||||
}
|
||||
|
||||
const DOCKVIEW_THEME: DockviewTheme = {
|
||||
name: 'Abyss',
|
||||
className: 'dockview-theme-abyss',
|
||||
colorScheme: 'dark'
|
||||
}
|
||||
|
||||
const DOCKVIEW_COMPONENTS = {
|
||||
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
|
||||
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
||||
)
|
||||
}
|
||||
|
||||
const DOCKVIEW_TAB_COMPONENTS = {
|
||||
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
||||
<TerminalTab {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron re-throws a main-process rejection as
|
||||
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real
|
||||
* cause has to be unwrapped to be worth showing.
|
||||
*/
|
||||
function connectFailureMessage(error: unknown): string {
|
||||
const generic = t('workspace.connect.failed')
|
||||
if (!(error instanceof Error)) return generic
|
||||
const detail = error.message
|
||||
.replace(/^Error invoking remote method '[^']*':\s*/, '')
|
||||
.replace(/^Error:\s*/, '')
|
||||
.trim()
|
||||
return detail ? `${generic}: ${detail}` : generic
|
||||
}
|
||||
|
||||
/**
|
||||
* "终端 N" titles fill the lowest free number among live panels: closing a
|
||||
* pane frees its number for the next new terminal, and a session restore can
|
||||
@@ -230,7 +279,7 @@ const SshTabActions = createTabActions('ssh')
|
||||
* (React state / panel instances stay mounted, so the switch is free).
|
||||
*
|
||||
* Sessions: panels carry a `params.sessionId`. Killing a session is idempotent
|
||||
* through `deadSessionsRef` so a session is killed at most once — either
|
||||
* through the `deadSessions` set so a session is killed at most once — either
|
||||
* up-front (TerminalView `onClose` after the process exits) or when its panel
|
||||
* is removed. Each dockview registers `onDidRemovePanel` so a panel close in
|
||||
* either workspace kills the bound session.
|
||||
@@ -255,8 +304,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** The currently-visible mode's active panel (broadcast target). */
|
||||
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
|
||||
/** Sessions we already asked to kill — kill once, never twice. */
|
||||
const deadSessionsRef = useRef<Set<string>>(new Set())
|
||||
/** Bind-time disposables for both dockviews, disposed on unmount. */
|
||||
const disposablesRef = useRef<Array<{ dispose(): void }>>([])
|
||||
|
||||
@@ -264,6 +311,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const [applyOpen, setApplyOpen] = useState(false)
|
||||
/** Terminal dockview ready — gates first-run seeding. */
|
||||
const [terminalReady, setTerminalReady] = useState(false)
|
||||
/** Settings hydrate is async — the boot pass must not read its defaults. */
|
||||
const settingsHydrated = useSettingsStore((s) => s.hydrated)
|
||||
|
||||
/** Local terminal panels surfaced to the sidebar's "终端" section. */
|
||||
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
|
||||
@@ -283,8 +332,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}, [])
|
||||
/** one pending SSH connection request (null = idle). */
|
||||
const [requestedConn, setRequestedConn] = useState<SshConnection | null>(null)
|
||||
/** true while openSession is in flight — drives the "连接中…" modal. */
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
/** In-flight openSession attempts (0 = idle); the rail busy dot reads it. */
|
||||
const [connectingCount, setConnectingCount] = useState(0)
|
||||
const connecting = connectingCount > 0
|
||||
/** Re-entrancy guard for `connect` — a second attempt while one is in flight
|
||||
* is dropped (openSession cannot be aborted and would orphan a session). */
|
||||
const connectInFlightRef = useRef(0)
|
||||
/** FIFO of pending host-key confirmations — render the head only. */
|
||||
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([])
|
||||
|
||||
@@ -342,16 +395,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** Idempotently kill a pty session. */
|
||||
const killSession = useCallback((sessionId: string | undefined): void => {
|
||||
if (!sessionId) return
|
||||
if (deadSessionsRef.current.has(sessionId)) return
|
||||
deadSessionsRef.current.add(sessionId)
|
||||
if (deadSessions.has(sessionId)) return
|
||||
deadSessions.add(sessionId)
|
||||
window.api.killPty(sessionId)
|
||||
}, [])
|
||||
|
||||
/** Mark a session dead ahead of a panel close so the cleanup skips it. */
|
||||
const markSessionDead = useCallback((sessionId: string): void => {
|
||||
deadSessionsRef.current.add(sessionId)
|
||||
}, [])
|
||||
|
||||
const createSession = useCallback(async (cwd?: string): Promise<string> => {
|
||||
// `cwd` is the memory hook: a restored pane and a freshly opened one both
|
||||
// start where the user left off. The main process validates the path and
|
||||
@@ -366,14 +414,19 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
* terminal dockview holds only local sessions, so it is the single source. */
|
||||
const recomputeLocalPanels = useCallback((): void => {
|
||||
const current = terminalApiRef.current
|
||||
if (!current) {
|
||||
setLocalPanels([])
|
||||
return
|
||||
}
|
||||
const panels = current.panels
|
||||
const panels = current
|
||||
? current.panels
|
||||
.filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local')
|
||||
.map((p) => ({ id: p.id, title: p.title ?? '' }))
|
||||
setLocalPanels(panels)
|
||||
: []
|
||||
// Keep the previous array when nothing changed: a fresh array on every
|
||||
// layout event re-renders the sidebar (and the whole workspace) for nothing.
|
||||
setLocalPanels((prev) =>
|
||||
prev.length === panels.length &&
|
||||
prev.every((panel, i) => panel.id === panels[i].id && panel.title === panels[i].title)
|
||||
? prev
|
||||
: panels
|
||||
)
|
||||
}, [])
|
||||
|
||||
/** Recount session panels per dockview into local state. */
|
||||
@@ -426,6 +479,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}
|
||||
useCount.delete(sid)
|
||||
killSession(sid)
|
||||
// No pane shows this session any more: drop its bookkeeping too, so
|
||||
// neither the dead-session set nor the cwd map grows for the whole run.
|
||||
deadSessions.delete(sid)
|
||||
forgetSessionCwd(sid)
|
||||
}
|
||||
|
||||
const cleanups = [
|
||||
@@ -522,28 +579,41 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
* Boot pass: pick up where the user left off — layout plus each pane's
|
||||
* directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no
|
||||
* snapshot exists) seed one terminal. Either way the workspace is never empty.
|
||||
*
|
||||
* Gated on the settings hydrate: reading `restoreSession` before it lands
|
||||
* always sees the defaults, so a user who turned restore off got their session
|
||||
* back whenever the dockview became ready first.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!terminalReady) return
|
||||
if (!terminalReady || !settingsHydrated) return
|
||||
let cancelled = false
|
||||
void (async (): Promise<void> => {
|
||||
const current = terminalApiRef.current
|
||||
if (!current) return
|
||||
try {
|
||||
const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false
|
||||
const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null
|
||||
if (cancelled) return
|
||||
const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0))
|
||||
const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false
|
||||
if (cancelled) return
|
||||
if (!restored && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
|
||||
if (!restored && !current.panels.some((p) => sessionIdOf(p))) await addTerminal()
|
||||
} catch (error) {
|
||||
// An escaped rejection used to leave the workspace half-applied: the
|
||||
// boot flag never flipped (so no snapshot was ever saved again) and the
|
||||
// panes the pass had not reached stayed bound to dead session ids.
|
||||
console.error('[workspace] session restore failed', error)
|
||||
if (!cancelled) message.error(t('workspace.error.title'))
|
||||
} finally {
|
||||
bootDoneRef.current = true
|
||||
scheduleSave()
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready
|
||||
}, [terminalReady])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once, on ready + hydrated
|
||||
}, [terminalReady, settingsHydrated])
|
||||
|
||||
/** Kill remaining sessions on window close. */
|
||||
useEffect(() => {
|
||||
@@ -649,10 +719,15 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
* stream; `connectionId` + any staged `secretOverride` are the only inputs
|
||||
* the main process needs — no extra IPC channel. New sessions land in the ssh
|
||||
* dockview.
|
||||
*
|
||||
* Re-entrant calls are dropped: a double-click would otherwise open two
|
||||
* sessions for one click, and openSession cannot be aborted once it is away.
|
||||
*/
|
||||
const connect = useCallback(
|
||||
async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => {
|
||||
setConnecting(true)
|
||||
if (connectInFlightRef.current > 0) return
|
||||
connectInFlightRef.current += 1
|
||||
setConnectingCount((count) => count + 1)
|
||||
try {
|
||||
const result = await window.api.openSession({
|
||||
kind: 'ssh',
|
||||
@@ -674,9 +749,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
// B's sidebar refreshes lastConnectedAt on connect.
|
||||
void sidebarRef.current?.refresh()
|
||||
} catch (error) {
|
||||
message.error(typeof error === 'string' ? error : t('workspace.connect.failed'))
|
||||
message.error(connectFailureMessage(error))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
connectInFlightRef.current -= 1
|
||||
setConnectingCount((count) => count - 1)
|
||||
setRequestedConn(null)
|
||||
}
|
||||
},
|
||||
@@ -685,6 +761,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
const handleConnectRequest = useCallback(
|
||||
(conn: SshConnection): void => {
|
||||
if (connectInFlightRef.current > 0) return
|
||||
// Ask-at-connect connections go through the secret prompt first; saved
|
||||
// credentials must connect IMMEDIATELY — routing them through ConnectFlow
|
||||
// would only ever *render* a "connecting" spinner without firing openSession.
|
||||
@@ -694,6 +771,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
setRequestedConn(conn)
|
||||
return
|
||||
}
|
||||
// Immediate path: show the same uncancellable "连接中" modal the secret
|
||||
// path uses, so the server list is not clickable while openSession flies.
|
||||
setRequestedConn(conn)
|
||||
void connect(conn, {})
|
||||
},
|
||||
[connect]
|
||||
@@ -783,16 +863,35 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
* panel's `<TerminalView>` with the new `sessionId` prop exactly the way it
|
||||
* does when the user switches terminals — its bind effect tears the old xterm
|
||||
* down and replays the new session's output.
|
||||
*
|
||||
* The owning dockview api comes along so the panel's survival can be checked
|
||||
* after the await: a pane closed while its pty was still starting would
|
||||
* otherwise leave that fresh session behind, bound to no panel and never
|
||||
* killed.
|
||||
*/
|
||||
const bindPanelSession = useCallback(
|
||||
async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise<void> => {
|
||||
if (!panel) return
|
||||
async (
|
||||
api: DockviewApi | undefined,
|
||||
panel: DockviewPanelApi | undefined,
|
||||
currentSessionId: string,
|
||||
cwd?: string
|
||||
): Promise<void> => {
|
||||
if (!api || !panel) return
|
||||
try {
|
||||
const nextSessionId = await createSession(cwd)
|
||||
if (!api.getPanel(panel.id)) {
|
||||
killSession(nextSessionId)
|
||||
forgetSessionCwd(nextSessionId)
|
||||
return
|
||||
}
|
||||
panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' })
|
||||
deadSessionsRef.current.delete(currentSessionId)
|
||||
deadSessions.delete(currentSessionId)
|
||||
forgetSessionCwd(currentSessionId)
|
||||
} catch (error) {
|
||||
console.error('[workspace] panel rebind failed', error)
|
||||
}
|
||||
},
|
||||
[createSession]
|
||||
[createSession, killSession]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -836,7 +935,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
panel.api.close()
|
||||
continue
|
||||
}
|
||||
await bindPanelSession(panel.api, id, lookup(panel.id)?.cwd)
|
||||
// One pane failing to rebind must not abort the panes after it (they
|
||||
// would keep the stale session id of a previous launch and stay dead).
|
||||
try {
|
||||
await bindPanelSession(api, panel.api, id, lookup(panel.id)?.cwd)
|
||||
} catch (error) {
|
||||
console.error('[workspace] pane rebind failed', panel.id, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[bindPanelSession]
|
||||
@@ -879,8 +984,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
// Retitle duplicates left over from the old counter bug (the snapshot it
|
||||
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
||||
// the title, later ones get the lowest free number.
|
||||
//
|
||||
// Local terminals only: an SSH pane is titled with its server name, and a
|
||||
// split deliberately shares that title with its sibling, so the terminal
|
||||
// numbering pattern is the wrong remedy there.
|
||||
const seenTitles = new Set<string>()
|
||||
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
||||
if ((p.params as TerminalParams | undefined)?.sessionKind === 'ssh') continue
|
||||
const t = p.title ?? ''
|
||||
if (!t) continue
|
||||
if (seenTitles.has(t)) {
|
||||
@@ -1014,19 +1124,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
<div className={workspaceMode === 'terminal' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
||||
<DockviewReact
|
||||
className="dockview-theme-abyss"
|
||||
components={{
|
||||
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
|
||||
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
||||
)
|
||||
}}
|
||||
tabComponents={{
|
||||
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
||||
<TerminalTab {...props} />
|
||||
)
|
||||
}}
|
||||
components={DOCKVIEW_COMPONENTS}
|
||||
tabComponents={DOCKVIEW_TAB_COMPONENTS}
|
||||
rightHeaderActionsComponent={TerminalTabActions}
|
||||
onReady={handleTerminalReady}
|
||||
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
|
||||
theme={DOCKVIEW_THEME}
|
||||
/>
|
||||
{terminalCount === 0 && (
|
||||
<div className="workspace-empty-overlay">
|
||||
@@ -1048,19 +1150,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
<div className={workspaceMode === 'ssh' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
|
||||
<DockviewReact
|
||||
className="dockview-theme-abyss"
|
||||
components={{
|
||||
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
|
||||
<TerminalPanel {...props} onSessionDead={markSessionDead} />
|
||||
)
|
||||
}}
|
||||
tabComponents={{
|
||||
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
|
||||
<TerminalTab {...props} />
|
||||
)
|
||||
}}
|
||||
components={DOCKVIEW_COMPONENTS}
|
||||
tabComponents={DOCKVIEW_TAB_COMPONENTS}
|
||||
rightHeaderActionsComponent={SshTabActions}
|
||||
onReady={handleSshReady}
|
||||
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
|
||||
theme={DOCKVIEW_THEME}
|
||||
/>
|
||||
{sshCount === 0 && (
|
||||
<div className="workspace-empty-overlay">
|
||||
|
||||
@@ -83,6 +83,16 @@ export default function ZmodemOffers(): null | React.JSX.Element {
|
||||
|
||||
const offDone = useCallback(
|
||||
(evt: { id: string; ok: boolean; message?: string }) => {
|
||||
// The offer can be finished by main without an answer — its 120s watchdog
|
||||
// aborts it and reports ok:false. Drop the entry either way, otherwise a
|
||||
// dead offer keeps an unclosable modal (and its busy flag) alive.
|
||||
busyRef.current.delete(evt.id)
|
||||
setOffers((prev) => {
|
||||
if (!prev.has(evt.id)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(evt.id)
|
||||
return next
|
||||
})
|
||||
if (evt.ok) message.success(t('ssh.zmodem.done'))
|
||||
else
|
||||
message.error(
|
||||
|
||||
@@ -2,10 +2,17 @@ import { create } from 'zustand'
|
||||
|
||||
/** One open terminal session eligible to be a broadcast target. */
|
||||
export interface BroadcastSession {
|
||||
/** pty / ssh session id (also the dockview panel id) */
|
||||
/** pty / ssh session id */
|
||||
id: string
|
||||
title: string
|
||||
isSsh: boolean
|
||||
/**
|
||||
* dockview panel showing this session. One session can back several panels —
|
||||
* an SSH split deliberately mirrors a single session — so registrations are
|
||||
* keyed by panel: closing one pane must not unregister the pane that is still
|
||||
* showing that session.
|
||||
*/
|
||||
panelId: string
|
||||
}
|
||||
|
||||
export interface BroadcastState {
|
||||
@@ -15,10 +22,13 @@ export interface BroadcastState {
|
||||
targets: Set<string>
|
||||
/** open terminal sessions, registration order preserved */
|
||||
sessions: BroadcastSession[]
|
||||
/** register a session as broadcast-eligible (idempotent, keeps it fresh) */
|
||||
/** register a panel's session as broadcast-eligible (upsert, keeps it fresh) */
|
||||
registerSession: (session: BroadcastSession) => void
|
||||
/** remove a session; if it was a target, prune it (may auto-disable <2 targets) */
|
||||
unregisterSession: (id: string) => void
|
||||
/**
|
||||
* drop one panel's registration; the session leaves `sessions` (and the
|
||||
* targets, possibly auto-disabling) only once no panel shows it any more
|
||||
*/
|
||||
unregisterSession: (panelId: string) => void
|
||||
/** add/remove one target; never lets the set drop below 2 while enabled */
|
||||
toggleTarget: (id: string) => void
|
||||
/** turn broadcast on/off */
|
||||
@@ -43,29 +53,42 @@ function pruneOnTargetLoss(enabled: boolean, nextTargets: Set<string>): { enable
|
||||
return { enabled, targets: nextTargets }
|
||||
}
|
||||
|
||||
/**
|
||||
* panelId → registration. The session list is derived from these rather than
|
||||
* maintained alongside them: one session can back several panels (an SSH split
|
||||
* mirrors a single session on purpose), and only the last panel to unmount may
|
||||
* take the session out of the registry.
|
||||
*/
|
||||
const registrations = new Map<string, BroadcastSession>()
|
||||
|
||||
/** One entry per session, in first-registration order, latest title wins. */
|
||||
function deriveSessions(): BroadcastSession[] {
|
||||
const bySession = new Map<string, BroadcastSession>()
|
||||
for (const registration of registrations.values()) bySession.set(registration.id, registration)
|
||||
return [...bySession.values()]
|
||||
}
|
||||
|
||||
export const useBroadcastStore = create<BroadcastState>((set) => ({
|
||||
enabled: false,
|
||||
targets: new Set(),
|
||||
sessions: [],
|
||||
|
||||
registerSession: (session) =>
|
||||
set((state) => {
|
||||
const exists = state.sessions.some((s) => s.id === session.id)
|
||||
if (exists) {
|
||||
// keep title/isSsh fresh (e.g. template apply retitles a panel)
|
||||
const sessions = state.sessions.map((s) => (s.id === session.id ? session : s))
|
||||
const targets = new Set(state.targets)
|
||||
return { sessions, targets }
|
||||
}
|
||||
return { sessions: [...state.sessions, session] }
|
||||
}),
|
||||
registerSession: (session) => {
|
||||
registrations.set(session.panelId, session)
|
||||
set({ sessions: deriveSessions() })
|
||||
},
|
||||
|
||||
unregisterSession: (id) =>
|
||||
unregisterSession: (panelId) =>
|
||||
set((state) => {
|
||||
const sessions = state.sessions.filter((s) => s.id !== id)
|
||||
if (!state.targets.has(id)) return { sessions }
|
||||
const dropped = registrations.get(panelId)
|
||||
if (!dropped) return {}
|
||||
registrations.delete(panelId)
|
||||
const sessions = deriveSessions()
|
||||
// another pane still shows this session — keep it, and its target slot
|
||||
if (sessions.some((s) => s.id === dropped.id)) return { sessions }
|
||||
if (!state.targets.has(dropped.id)) return { sessions }
|
||||
const targets = new Set(state.targets)
|
||||
targets.delete(id)
|
||||
targets.delete(dropped.id)
|
||||
return { sessions, ...pruneOnTargetLoss(state.enabled, targets) }
|
||||
}),
|
||||
|
||||
@@ -74,7 +97,11 @@ export const useBroadcastStore = create<BroadcastState>((set) => ({
|
||||
const targets = new Set(state.targets)
|
||||
if (targets.has(id)) {
|
||||
targets.delete(id)
|
||||
return pruneOnTargetLoss(state.enabled, targets)
|
||||
// Deliberate uncheck: keep the remaining selection — broadcast just
|
||||
// turns off below the 2-target minimum. Only session loss (see
|
||||
// unregisterSession) may wipe the set.
|
||||
if (targets.size < 2) return { enabled: false, targets }
|
||||
return { enabled: state.enabled, targets }
|
||||
}
|
||||
targets.add(id)
|
||||
// requires >= 2 targets to become/become-enabled
|
||||
|
||||
+3
-1
@@ -97,7 +97,7 @@ export interface AppApi {
|
||||
|
||||
// ---- settings ----
|
||||
getSettings(): Promise<AppSettings>
|
||||
saveSettings(next: AppSettings): Promise<AppSettings>
|
||||
saveSettings(next: Partial<AppSettings>): Promise<AppSettings>
|
||||
onSettingsChanged(cb: (s: AppSettings) => void): () => void
|
||||
|
||||
// ---- fonts ----
|
||||
@@ -112,6 +112,8 @@ export interface AppApi {
|
||||
|
||||
// ---- updater (domestic feed first, GitHub fallback) ----
|
||||
updateCheck(): Promise<UpdateState>
|
||||
/** read the updater's current state without triggering a network check */
|
||||
getUpdateState(): Promise<UpdateState>
|
||||
updateDownload(): Promise<void>
|
||||
updateInstall(): Promise<void>
|
||||
updateChangelog(): Promise<ReleaseNote[]>
|
||||
|
||||
@@ -13,7 +13,7 @@ const main: Record<string, string> = {
|
||||
'main.tray.closeDetail': 'Minimizing to tray keeps your terminal sessions running.',
|
||||
'main.tray.rememberChoice': 'Remember my choice, never ask again',
|
||||
|
||||
'main.updater.feedFailed': 'Domestic feed: {gitea}; GitHub: {github}',
|
||||
'main.updater.feedFailed': 'Gitea feed: {gitea}; GitHub: {github}',
|
||||
|
||||
'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}',
|
||||
@@ -27,6 +27,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.deleteFailed': 'Delete failed: {detail}',
|
||||
'main.sftp.invalidMode': 'Invalid permission value: {mode}',
|
||||
'main.sftp.invalidUidGid': 'Invalid uid/gid',
|
||||
'main.sftp.commandTimeout': 'Command timed out',
|
||||
'main.sftp.commandExitCode': 'Command exited with code {code}',
|
||||
'main.sftp.cancelled': 'Cancelled',
|
||||
|
||||
@@ -47,6 +48,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.sysinfo.sessionGone': 'SSH session is missing or disconnected',
|
||||
'main.sysinfo.execFailed': 'SSH exec failed',
|
||||
'main.sysinfo.pollTimeout': 'Monitoring command timed out',
|
||||
'main.sysinfo.parseFailed': 'Parse failed: {message}',
|
||||
'main.pty.serviceNotReady': 'SSH session service is not initialised yet',
|
||||
'main.pty.missingConnectionId': 'SSH session is missing its connectionId',
|
||||
|
||||
@@ -98,6 +98,11 @@ const settings: Record<string, string> = {
|
||||
'Together with the global key this shows or hides the window from anywhere; if registration fails (a conflict with another app) it simply has no effect and the app reports no error.',
|
||||
'settings.system.shortcutRecording': 'Press the keys…',
|
||||
'settings.system.shortcutPlaceholder': 'Click and press a shortcut, leave empty to disable',
|
||||
'settings.system.shortcutConflict':
|
||||
'This shortcut is already used by the app (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn). Please choose another.',
|
||||
'settings.resetTerminal': 'Reset terminal settings',
|
||||
'settings.resetTerminalTitle': 'Reset terminal settings?',
|
||||
'settings.resetTerminalDesc': 'All terminal settings — font, cursor, rendering and theme — will be restored to defaults.',
|
||||
'settings.highlight.alert':
|
||||
'Custom highlighting improves readability at a small performance cost. Rules are applied in priority order. PS: colors are injected as text is written to the terminal, so existing scrollback is unaffected',
|
||||
'settings.highlight.enabled': 'Enabled',
|
||||
@@ -121,6 +126,17 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.resetOk': 'Restore',
|
||||
'settings.highlight.createTitle': 'New highlight rule',
|
||||
'settings.highlight.editTitle': 'Edit highlight rule',
|
||||
'settings.highlight.builtin.perm': 'Linux permissions and user/group',
|
||||
'settings.highlight.builtin.path': 'Linux file paths',
|
||||
'settings.highlight.builtin.shellkw': 'Shell keywords and control flow',
|
||||
'settings.highlight.builtin.okstate': 'Success states',
|
||||
'settings.highlight.builtin.badstate': 'Error/warning states',
|
||||
'settings.highlight.builtin.quoted': 'Strings and quotes',
|
||||
'settings.highlight.builtin.envvar': 'Environment variables and arguments',
|
||||
'settings.highlight.builtin.ipv4': 'Network and IP addresses',
|
||||
'settings.highlight.builtin.datetime': 'Time and date',
|
||||
'settings.highlight.builtin.numbers': 'Numbers and counts',
|
||||
'settings.highlight.builtin.url': 'Web links',
|
||||
'settings.theme.accent': 'Tab accent color',
|
||||
'settings.theme.accentDesc': 'Accent color for the active tab border, the sidebar indicator, and SSH badges',
|
||||
'settings.theme.customBadge': 'Custom',
|
||||
|
||||
@@ -13,7 +13,7 @@ const ssh: Record<string, string> = {
|
||||
'ssh.fields.askPasswordHint':
|
||||
'Prompts for the password on every connection instead of using the saved one',
|
||||
'ssh.fields.passwordPlaceholder': 'Leave empty to keep the saved password',
|
||||
'ssh.fields.keyPath': 'Private key path (path on the server, optional)',
|
||||
'ssh.fields.keyPath': 'Private key file path (on this computer, optional)',
|
||||
'ssh.fields.keyContent': 'Private key content',
|
||||
'ssh.fields.keyContentPlaceholder':
|
||||
'Paste the private key; leave empty to use the path above (leave empty while editing to keep the saved key)',
|
||||
@@ -100,6 +100,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.up': 'Parent folder',
|
||||
'ssh.file.refresh': 'Refresh',
|
||||
'ssh.file.uploadFile': 'Upload files',
|
||||
'ssh.file.overwriteTitle': 'Overwrite existing file?',
|
||||
'ssh.file.overwriteDesc': 'A file with the same name exists in the target directory; uploading will overwrite its contents.',
|
||||
'ssh.file.downloadSelected': 'Download selection',
|
||||
'ssh.file.permOwner': 'Permissions / owner',
|
||||
'ssh.file.open': 'Open',
|
||||
|
||||
@@ -13,7 +13,7 @@ const main: Record<string, string> = {
|
||||
'main.tray.closeDetail': 'トレイに最小化すると、ターミナルセッションは実行を続けます。',
|
||||
'main.tray.rememberChoice': '選択を記憶して今後確認しない',
|
||||
|
||||
'main.updater.feedFailed': '国内フィード: {gitea};GitHub: {github}',
|
||||
'main.updater.feedFailed': 'Gitea フィード: {gitea};GitHub: {github}',
|
||||
|
||||
'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})',
|
||||
'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}',
|
||||
@@ -27,6 +27,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.deleteFailed': '削除に失敗しました: {detail}',
|
||||
'main.sftp.invalidMode': '不正なパーミッション値: {mode}',
|
||||
'main.sftp.invalidUidGid': '不正な uid/gid',
|
||||
'main.sftp.commandTimeout': 'コマンドがタイムアウトしました',
|
||||
'main.sftp.commandExitCode': 'コマンドの終了コード {code}',
|
||||
'main.sftp.cancelled': 'キャンセルしました',
|
||||
|
||||
@@ -47,6 +48,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.sysinfo.sessionGone': 'SSH セッションが存在しないか切断されています',
|
||||
'main.sysinfo.execFailed': 'SSH exec に失敗しました',
|
||||
'main.sysinfo.pollTimeout': '監視コマンドがタイムアウトしました',
|
||||
'main.sysinfo.parseFailed': '解析に失敗しました: {message}',
|
||||
'main.pty.serviceNotReady': 'SSH セッションサービスが未初期化です',
|
||||
'main.pty.missingConnectionId': 'SSH セッションに connectionId がありません',
|
||||
|
||||
@@ -95,6 +95,11 @@ const settings: Record<string, string> = {
|
||||
'グローバルキーと組み合わせると、どの画面からでもウィンドウを呼び出し/非表示にできます。登録に失敗した場合(他のソフトとの競合)は効果がなく、アプリにエラーは表示されません。',
|
||||
'settings.system.shortcutRecording': 'キーを押してください…',
|
||||
'settings.system.shortcutPlaceholder': 'クリックしてショートカットを押す。空欄で無効',
|
||||
'settings.system.shortcutConflict':
|
||||
'このショートカットはアプリ内で既に使用されています(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn)。別のものを選んでください。',
|
||||
'settings.resetTerminal': 'ターミナル設定をリセット',
|
||||
'settings.resetTerminalTitle': 'ターミナル設定をリセットしますか?',
|
||||
'settings.resetTerminalDesc': 'フォント、カーソル、レンダリング、テーマなどすべてのターミナル設定がデフォルトに戻ります。',
|
||||
'settings.highlight.alert':
|
||||
'独自のハイライトは見やすさを高めますが、わずかに性能を消費します。ルールは優先順に適用されます。PS: ハイライトはターミナルへの書き込み時に色を注入するため、既存のスクロールバックには反映されません',
|
||||
'settings.highlight.enabled': '有効',
|
||||
@@ -118,6 +123,17 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.resetOk': '元に戻す',
|
||||
'settings.highlight.createTitle': 'ハイライトルールを作成',
|
||||
'settings.highlight.editTitle': 'ハイライトルールを編集',
|
||||
'settings.highlight.builtin.perm': 'Linux のパーミッションとユーザー',
|
||||
'settings.highlight.builtin.path': 'Linux のファイルパス',
|
||||
'settings.highlight.builtin.shellkw': 'Shell のキーワードと制御構文',
|
||||
'settings.highlight.builtin.okstate': '成功ステータス',
|
||||
'settings.highlight.builtin.badstate': 'エラー・警告ステータス',
|
||||
'settings.highlight.builtin.quoted': '文字列と引用符',
|
||||
'settings.highlight.builtin.envvar': '環境変数と引数',
|
||||
'settings.highlight.builtin.ipv4': 'ネットワークと IP アドレス',
|
||||
'settings.highlight.builtin.datetime': '時刻と日付',
|
||||
'settings.highlight.builtin.numbers': '数値とカウント',
|
||||
'settings.highlight.builtin.url': 'ウェブリンク',
|
||||
'settings.theme.accent': 'タブのアクセントカラー',
|
||||
'settings.theme.accentDesc': 'アクティブなタブの枠線、サイドバーのインジケーター、SSH バッジのアクセントカラー',
|
||||
'settings.theme.customBadge': 'カスタム',
|
||||
|
||||
@@ -13,7 +13,7 @@ const ssh: Record<string, string> = {
|
||||
'ssh.fields.askPasswordHint':
|
||||
'オンにすると毎回の接続でパスワード入力ダイアログを表示し、保存済みのパスワードを使いません',
|
||||
'ssh.fields.passwordPlaceholder': '編集時に空欄のままにすると保存済みのパスワードを保持します',
|
||||
'ssh.fields.keyPath': '秘密鍵のパス(サーバー上の秘密鍵のパス、省略可)',
|
||||
'ssh.fields.keyPath': '秘密鍵ファイルのパス(このコンピューター上のパス、省略可)',
|
||||
'ssh.fields.keyContent': '秘密鍵の内容',
|
||||
'ssh.fields.keyContentPlaceholder':
|
||||
'秘密鍵の内容を貼り付けます。空欄の場合は上のパスを使用します(編集時に空欄のままにすると保存済みの秘密鍵を保持します)',
|
||||
@@ -102,6 +102,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.up': '親フォルダへ',
|
||||
'ssh.file.refresh': '更新',
|
||||
'ssh.file.uploadFile': 'ファイルをアップロード',
|
||||
'ssh.file.overwriteTitle': '既存のファイルを上書きしますか?',
|
||||
'ssh.file.overwriteDesc': '対象ディレクトリに同名のファイルが存在します。アップロードすると内容が上書きされます。',
|
||||
'ssh.file.downloadSelected': '選択項目をダウンロード',
|
||||
'ssh.file.permOwner': '権限 / 所有者',
|
||||
'ssh.file.open': '開く',
|
||||
|
||||
@@ -27,6 +27,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.deleteFailed': '删除失败: {detail}',
|
||||
'main.sftp.invalidMode': '非法权限值: {mode}',
|
||||
'main.sftp.invalidUidGid': '非法 uid/gid',
|
||||
'main.sftp.commandTimeout': '命令执行超时',
|
||||
'main.sftp.commandExitCode': '命令退出码 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
|
||||
@@ -47,6 +48,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.sysinfo.sessionGone': 'SSH 会话不存在或已断开',
|
||||
'main.sysinfo.execFailed': 'SSH exec 失败',
|
||||
'main.sysinfo.pollTimeout': '监控命令响应超时',
|
||||
'main.sysinfo.parseFailed': '解析失败: {message}',
|
||||
'main.pty.serviceNotReady': 'SSH 会话服务尚未初始化',
|
||||
'main.pty.missingConnectionId': 'SSH 会话缺少 connectionId',
|
||||
|
||||
@@ -92,6 +92,11 @@ const settings: Record<string, string> = {
|
||||
'配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。',
|
||||
'settings.system.shortcutRecording': '按下组合键…',
|
||||
'settings.system.shortcutPlaceholder': '点击后按下快捷键,留空禁用',
|
||||
'settings.system.shortcutConflict':
|
||||
'该组合键已被应用内快捷键占用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),请换一个。',
|
||||
'settings.resetTerminal': '恢复默认终端设置',
|
||||
'settings.resetTerminalTitle': '恢复默认终端设置?',
|
||||
'settings.resetTerminalDesc': '字体、光标、渲染与主题等全部终端设置将恢复为默认值。',
|
||||
'settings.highlight.alert':
|
||||
'自定义高亮可增强显示效果,但会略微增加性能消耗。规则按优先级顺序应用。PS: 高亮在写入终端时注入颜色,对已有回滚内容不生效',
|
||||
'settings.highlight.enabled': '启用',
|
||||
@@ -115,6 +120,17 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.resetOk': '恢复',
|
||||
'settings.highlight.createTitle': '新建高亮规则',
|
||||
'settings.highlight.editTitle': '编辑高亮规则',
|
||||
'settings.highlight.builtin.perm': 'Linux 权限与用户',
|
||||
'settings.highlight.builtin.path': 'Linux 文件路径',
|
||||
'settings.highlight.builtin.shellkw': 'Shell 关键字与流程',
|
||||
'settings.highlight.builtin.okstate': '成功状态',
|
||||
'settings.highlight.builtin.badstate': '错误/警告状态',
|
||||
'settings.highlight.builtin.quoted': '字符串与引号',
|
||||
'settings.highlight.builtin.envvar': '环境变量与参数',
|
||||
'settings.highlight.builtin.ipv4': '网络与 IP 地址',
|
||||
'settings.highlight.builtin.datetime': '时间与日期',
|
||||
'settings.highlight.builtin.numbers': '数字与计数',
|
||||
'settings.highlight.builtin.url': '网址链接',
|
||||
'settings.theme.accent': '标签强调色',
|
||||
'settings.theme.accentDesc': '活动标签边框、侧栏指示条与 SSH 徽章的强调颜色',
|
||||
'settings.theme.customBadge': '自定义',
|
||||
|
||||
@@ -11,7 +11,7 @@ const ssh: Record<string, string> = {
|
||||
'ssh.fields.askPasswordLabel': '连接时询问密码',
|
||||
'ssh.fields.askPasswordHint': '开启后每次连接弹出对话框输入密码,不再使用已保存的密码',
|
||||
'ssh.fields.passwordPlaceholder': '编辑时留空表示保留已保存的密码',
|
||||
'ssh.fields.keyPath': '私钥路径(服务器上的本地私钥路径,可留空)',
|
||||
'ssh.fields.keyPath': '私钥文件路径(本机路径,可留空)',
|
||||
'ssh.fields.keyContent': '私钥内容',
|
||||
'ssh.fields.keyContentPlaceholder':
|
||||
'粘贴私钥内容;留空则使用上面的路径(编辑时留空表示保留已保存的私钥)',
|
||||
@@ -94,6 +94,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.up': '上一级',
|
||||
'ssh.file.refresh': '刷新',
|
||||
'ssh.file.uploadFile': '上传文件',
|
||||
'ssh.file.overwriteTitle': '覆盖已存在的文件?',
|
||||
'ssh.file.overwriteDesc': '目标目录已存在同名文件,上传将覆盖其内容。',
|
||||
'ssh.file.downloadSelected': '下载选中项',
|
||||
'ssh.file.permOwner': '权限 / 属主',
|
||||
'ssh.file.open': '打开',
|
||||
|
||||
@@ -27,6 +27,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.deleteFailed': '刪除失敗: {detail}',
|
||||
'main.sftp.invalidMode': '權限值無效: {mode}',
|
||||
'main.sftp.invalidUidGid': 'uid/gid 無效',
|
||||
'main.sftp.commandTimeout': '命令執行逾時',
|
||||
'main.sftp.commandExitCode': '指令結束碼 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
|
||||
@@ -47,6 +48,7 @@ const main: Record<string, string> = {
|
||||
|
||||
'main.sysinfo.sessionGone': 'SSH 工作階段不存在或已中斷',
|
||||
'main.sysinfo.execFailed': 'SSH exec 失敗',
|
||||
'main.sysinfo.pollTimeout': '監控命令回應逾時',
|
||||
'main.sysinfo.parseFailed': '解析失敗: {message}',
|
||||
'main.pty.serviceNotReady': 'SSH 工作階段服務尚未初始化',
|
||||
'main.pty.missingConnectionId': 'SSH 工作階段缺少 connectionId',
|
||||
|
||||
@@ -92,6 +92,11 @@ const settings: Record<string, string> = {
|
||||
'搭配全域鍵可在任何畫面喚出/隱藏視窗;若註冊失敗(與其他軟體衝突)則不會生效,應用程式也不會報錯。',
|
||||
'settings.system.shortcutRecording': '請按下組合鍵…',
|
||||
'settings.system.shortcutPlaceholder': '點擊後按下快速鍵,留空為停用',
|
||||
'settings.system.shortcutConflict':
|
||||
'此組合鍵已被應用內快捷鍵佔用(Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+PgUp / Ctrl+PgDn),請換一個。',
|
||||
'settings.resetTerminal': '恢復預設終端設定',
|
||||
'settings.resetTerminalTitle': '恢復預設終端設定?',
|
||||
'settings.resetTerminalDesc': '字體、游標、渲染與主題等全部終端設定將恢復為預設值。',
|
||||
'settings.highlight.alert':
|
||||
'自訂高亮可強化顯示效果,但會略微增加效能負擔。規則依優先順序套用。PS:高亮是在寫入終端時注入顏色,對既有的回捲內容不生效',
|
||||
'settings.highlight.enabled': '啟用',
|
||||
@@ -115,6 +120,17 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.resetOk': '還原',
|
||||
'settings.highlight.createTitle': '新增高亮規則',
|
||||
'settings.highlight.editTitle': '編輯高亮規則',
|
||||
'settings.highlight.builtin.perm': 'Linux 權限與使用者',
|
||||
'settings.highlight.builtin.path': 'Linux 檔案路徑',
|
||||
'settings.highlight.builtin.shellkw': 'Shell 關鍵字與流程',
|
||||
'settings.highlight.builtin.okstate': '成功狀態',
|
||||
'settings.highlight.builtin.badstate': '錯誤/警告狀態',
|
||||
'settings.highlight.builtin.quoted': '字串與引號',
|
||||
'settings.highlight.builtin.envvar': '環境變數與參數',
|
||||
'settings.highlight.builtin.ipv4': '網路與 IP 位址',
|
||||
'settings.highlight.builtin.datetime': '時間與日期',
|
||||
'settings.highlight.builtin.numbers': '數字與計數',
|
||||
'settings.highlight.builtin.url': '網址連結',
|
||||
'settings.theme.accent': '標籤強調色',
|
||||
'settings.theme.accentDesc': '作用中標籤的邊框、側邊欄指示條與 SSH 徽章的強調顏色',
|
||||
'settings.theme.customBadge': '自訂',
|
||||
|
||||
@@ -11,7 +11,7 @@ const ssh: Record<string, string> = {
|
||||
'ssh.fields.askPasswordLabel': '連線時詢問密碼',
|
||||
'ssh.fields.askPasswordHint': '開啟後每次連線彈出對話框輸入密碼,不再使用已儲存的密碼',
|
||||
'ssh.fields.passwordPlaceholder': '編輯時留空表示保留已儲存的密碼',
|
||||
'ssh.fields.keyPath': '私鑰路徑(伺服器上的本機私鑰路徑,可留空)',
|
||||
'ssh.fields.keyPath': '私鑰檔案路徑(本機路徑,可留空)',
|
||||
'ssh.fields.keyContent': '私鑰內容',
|
||||
'ssh.fields.keyContentPlaceholder':
|
||||
'貼上私鑰內容;留空則使用上面的路徑(編輯時留空表示保留已儲存的私鑰)',
|
||||
@@ -94,6 +94,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.up': '上一層',
|
||||
'ssh.file.refresh': '重新整理',
|
||||
'ssh.file.uploadFile': '上傳檔案',
|
||||
'ssh.file.overwriteTitle': '覆蓋已存在的檔案?',
|
||||
'ssh.file.overwriteDesc': '目標目錄已存在同名檔案,上傳將覆蓋其內容。',
|
||||
'ssh.file.downloadSelected': '下載選取項目',
|
||||
'ssh.file.permOwner': '權限 / 擁有者',
|
||||
'ssh.file.open': '開啟',
|
||||
|
||||
@@ -53,6 +53,12 @@ const DICTS: Record<Language, Dict> = {
|
||||
ja
|
||||
}
|
||||
|
||||
/** Own-property read: dictionary literals would otherwise answer `constructor`,
|
||||
* `toString`, … straight out of Object.prototype and then throw in replace(). */
|
||||
function lookup(dict: Dict, key: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : undefined
|
||||
}
|
||||
|
||||
let current: Language = DEFAULT_LANGUAGE
|
||||
const listeners = new Set<(lang: Language) => void>()
|
||||
|
||||
@@ -64,6 +70,15 @@ export function setLanguage(lang: Language): void {
|
||||
for (const listener of listeners) listener(lang)
|
||||
}
|
||||
|
||||
/** Render-time sync for the React tree: update the module language WITHOUT
|
||||
* notifying subscribers — firing listeners during another component's render
|
||||
* makes React warn ("cannot update a component while rendering a different
|
||||
* component"). Renderer components re-render via the settings store instead;
|
||||
* the notifying setLanguage stays for the main process (tray rebuild). */
|
||||
export function syncLanguage(lang: Language): void {
|
||||
current = lang
|
||||
}
|
||||
|
||||
export function getLanguage(): Language {
|
||||
return current
|
||||
}
|
||||
@@ -75,7 +90,7 @@ export function onLanguageChange(cb: (lang: Language) => void): () => void {
|
||||
|
||||
/** Translate `key`; `{name}` placeholders are filled from `vars`. */
|
||||
export function t(key: string, vars?: Record<string, string | number>): string {
|
||||
const raw = DICTS[current][key] ?? DICTS[DEFAULT_LANGUAGE][key] ?? key
|
||||
const raw = lookup(DICTS[current], key) ?? lookup(DICTS[DEFAULT_LANGUAGE], key) ?? key
|
||||
if (!vars) return raw
|
||||
return raw.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
vars[name] === undefined ? match : String(vars[name])
|
||||
|
||||
@@ -97,6 +97,8 @@ export const Ipc = {
|
||||
UPDATE_DOWNLOAD: 'update:download',
|
||||
UPDATE_INSTALL: 'update:install',
|
||||
UPDATE_CHANGELOG: 'update:changelog',
|
||||
/** renderer pulls the current updater state without triggering a check */
|
||||
UPDATE_STATE_GET: 'update:stateGet',
|
||||
/** main -> renderer broadcast: UpdateState */
|
||||
UPDATE_STATE: 'update:state',
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Decisive test: exact sftp.ts upload code, inline (no esbuild bundle).
|
||||
import { createRequire } from 'module'
|
||||
import { promises as fsp } from 'fs'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const ssh2 = require_('ssh2')
|
||||
const sessionLayer = require_('D:/Coding/Terminal/tests/.session-e2e.cjs')
|
||||
|
||||
let clientProvider
|
||||
function p(fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn((err, res) => (err ? reject(err) : resolve(res)))
|
||||
})
|
||||
}
|
||||
|
||||
sessionLayer.configureSessionRuntime({
|
||||
broadcast: () => {},
|
||||
getConnection: () => ({
|
||||
id: 'jd', name: 't', host: process.env.JD_HOST, port: 22, username: process.env.JD_USER,
|
||||
auth: 'password', askPasswordAtConnect: false, askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 30, createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
|
||||
}),
|
||||
getSecret: () => process.env.JD_PASS,
|
||||
touch: () => {},
|
||||
knownHosts: { check: () => ({ status: 'match' }), accept: () => {} },
|
||||
promptHostKey: () => { throw new Error('x') }
|
||||
})
|
||||
|
||||
const { id } = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'jd' })
|
||||
const client = sessionLayer.getSshClient(id)
|
||||
|
||||
// --- exact sftp.ts upload pattern ---
|
||||
const transfer = { kind: 'upload', cancelled: false }
|
||||
const emit = (e) => console.log('[evt]', e.state, e.file, e.bytes + '/' + e.totalBytes, e.error ?? '')
|
||||
const local = './.exact-test.bin'
|
||||
const content = Buffer.alloc(256 * 1024, 2)
|
||||
await fsp.writeFile(local, content)
|
||||
const remote = '/root/ot_exact.bin'
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp) => (err != null ? reject(err) : resolve(sftp)))
|
||||
})
|
||||
const sftp = await new Promise((resolve, reject) => {
|
||||
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
|
||||
})
|
||||
console.log('[exact] sftp channel open, calling fastPut')
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.fastPut(local, remote, {
|
||||
concurrency: 4,
|
||||
step: (totalTransferred, _chunk, total) => {
|
||||
console.log('[exact] step', totalTransferred, '/', total)
|
||||
if (transfer.cancelled) {
|
||||
reject(new Error('已取消'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
const st = await new Promise((resolve, reject) => {
|
||||
sftp.stat(remote, (err, s) => (err ? reject(err) : resolve(s)))
|
||||
})
|
||||
console.log('[exact] OK, remote size:', st.size, 'expected:', content.length)
|
||||
await new Promise((resolve, reject) => sftp.unlink(remote, (e) => (e ? reject(e) : resolve())))
|
||||
await fsp.rm(local, { force: true })
|
||||
sessionLayer.killPty(id)
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Rebuild every esbuild bundle the tests load, so a test run can never exercise
|
||||
a stale bundle. `npm test` calls this first; run it standalone when you only
|
||||
want fresh bundles.
|
||||
|
||||
The aliases live here (and nowhere else) so every bundle resolves `electron`
|
||||
and `@shared` the same way. A bundle built without
|
||||
`--alias:@shared=./src/shared` does not even compile: pty.ts reaches
|
||||
@shared/theme through settingsStore.ts -> windowChrome.ts.
|
||||
|
||||
Usage: node tests/build-bundles.cjs */
|
||||
const path = require('node:path')
|
||||
const esbuild = require('esbuild')
|
||||
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
|
||||
const BUNDLES = [
|
||||
// Real session layer: pty.ts also re-exports the ssh + sysinfo engines.
|
||||
{ entry: 'src/main/pty.ts', out: 'tests/.session-e2e.cjs', external: ['@lydell/node-pty', 'ssh2'] },
|
||||
// ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`.
|
||||
{ entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] },
|
||||
{ entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' },
|
||||
{ entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' },
|
||||
// zmodem.js stays bundled (NOT external) — the test drives a second in-process
|
||||
// Sentry from the same library.
|
||||
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
|
||||
// The smoke test imports the renderer engine (.ts), so it needs bundling too.
|
||||
{ entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' }
|
||||
]
|
||||
|
||||
for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) {
|
||||
esbuild.buildSync({
|
||||
absWorkingDir: ROOT,
|
||||
entryPoints: [path.join(ROOT, entry)],
|
||||
outfile: path.join(ROOT, out),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format,
|
||||
external,
|
||||
alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared' },
|
||||
logLevel: 'warning'
|
||||
})
|
||||
console.log(`built ${out}`)
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* --alias:@shared=./src/shared
|
||||
* Run: node tests/commands-store.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
@@ -227,5 +227,9 @@ const expected =
|
||||
'partial-tail'
|
||||
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
|
||||
|
||||
// The store wrote into a temp userData dir; drop it so repeated runs do not
|
||||
// litter %TEMP%.
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
console.log('\n[commands] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
@@ -14,7 +14,7 @@
|
||||
* --alias:@shared=./src/shared
|
||||
* node tests/settings-store.mjs
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
@@ -111,5 +111,9 @@ console.log('[warnings log]')
|
||||
ok(readFileSync(file, 'utf8').length === size, 'identical warnings are not appended twice')
|
||||
}
|
||||
|
||||
// The store wrote settings into a temp userData dir; drop it so repeated runs do
|
||||
// not litter %TEMP%.
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
console.log(failed === 0 ? '\n[settings] ALL CHECKS PASSED' : `\n[settings] ${failed} CHECK(S) FAILED`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -1,6 +1,7 @@
|
||||
// Real-server chmod/chown + mode-string verification (M4). Credentials from env:
|
||||
// JD_HOST / JD_USER / JD_PASS
|
||||
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
|
||||
// Prereq bundles: `node tests/build-bundles.cjs` builds both of them —
|
||||
// tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
|
||||
import { createRequire } from 'module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const sessionLayer = require_('./.session-e2e.cjs')
|
||||
@@ -71,7 +72,8 @@ try {
|
||||
console.log('[chmod] cleanup ok')
|
||||
|
||||
const exits = events.filter((e) => e.channel === 'pty:exit')
|
||||
console.log('[chmod] unexpected exits:', exits.length)
|
||||
if (exits.length) fail(`session exited unexpectedly during the chmod test (${exits.length}x)`)
|
||||
console.log('[chmod] no unexpected session exits')
|
||||
|
||||
sessionLayer.killPty(id)
|
||||
console.log('[chmod] ALL CHECKS PASSED')
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
// Real-server SFTP verification (M4). Credentials from env (never hardcoded):
|
||||
// JD_HOST / JD_USER / JD_PASS
|
||||
// Prereq bundles: tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.cjs (sftp service)
|
||||
// Prereq bundles: `node tests/build-bundles.cjs` builds both of them —
|
||||
// tests/.session-e2e.cjs (pty session layer), tests/.sftp-svc.mjs (sftp service)
|
||||
import { createRequire } from 'module'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { promises as fsp } from 'fs'
|
||||
@@ -100,7 +101,8 @@ try {
|
||||
console.log('[sftp] recursive delete ok')
|
||||
|
||||
const exits = events.filter((e) => e.channel === 'pty:exit')
|
||||
console.log('[sftp] unexpected exits:', exits.length)
|
||||
if (exits.length) fail(`session exited unexpectedly during the transfer (${exits.length}x) — see the debug log above`)
|
||||
console.log('[sftp] no unexpected session exits')
|
||||
|
||||
await fsp.rm(localFile, { force: true })
|
||||
await fsp.rm(dlDir, { recursive: true, force: true })
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* in-process ssh2.Server — the same openSession/write/resize/kill pipeline the
|
||||
* app uses, minus the IPC + renderer shells.
|
||||
*
|
||||
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
|
||||
* Pre-req: node tests/build-bundles.cjs (or, by hand:)
|
||||
* npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
|
||||
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
|
||||
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
|
||||
* --alias:@shared=./src/shared
|
||||
* (--alias:@shared=./src/shared is required: pty.ts reaches @shared/theme
|
||||
* through settingsStore.ts -> windowChrome.ts)
|
||||
* Run: node tests/ssh-session-e2e.mjs (must exit 0)
|
||||
*/
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
* broadcast, growing samples (usage + net rates), and that stopPolling halts
|
||||
* the stream.
|
||||
*
|
||||
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
|
||||
* Pre-req: node tests/build-bundles.cjs (or, by hand:)
|
||||
* npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
|
||||
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
|
||||
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
|
||||
* --alias:@shared=./src/shared
|
||||
* (--alias:@shared=./src/shared is required: pty.ts reaches @shared/theme
|
||||
* through settingsStore.ts -> windowChrome.ts)
|
||||
* Run: node tests/sysinfo-e2e.mjs (must exit 0)
|
||||
*/
|
||||
|
||||
|
||||
+25
-13
@@ -38,6 +38,20 @@ const fail = (msg) => {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Temp dirs the scenarios create under tests/. They are removed on the way out,
|
||||
// including when fail() exits the process mid-scenario — hence the exit hook
|
||||
// next to the try/finally at the bottom.
|
||||
const tempDirs = []
|
||||
const mkTemp = (prefix) => {
|
||||
const dir = mkdtempSync(join(__dirname, prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
const cleanupTempDirs = () => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
process.on('exit', cleanupTempDirs)
|
||||
|
||||
// ---- 0. ensure bundle ---------------------------------------------------------
|
||||
const bundlePath = join(__dirname, '.zmodem-e2e.cjs')
|
||||
if (!existsSync(bundlePath)) {
|
||||
@@ -117,11 +131,11 @@ function makeRemoteSentry(remoteOnDetect, routeOut) {
|
||||
async function scenarioDownload() {
|
||||
console.log('\n=== 场景 1: 远端 sz + 本地接收 (download) ===')
|
||||
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
||||
const srcDir = mkdtempSync(join(__dirname, '.zm-src-dl-'))
|
||||
const srcDir = mkTemp('.zm-src-dl-')
|
||||
const srcFile = join(srcDir, 'transferred.bin')
|
||||
const payload = randomBytes(200_000)
|
||||
writeFileSync(srcFile, payload)
|
||||
const outDir = mkdtempSync(join(__dirname, '.zm-out-dl-'))
|
||||
const outDir = mkTemp('.zm-out-dl-')
|
||||
|
||||
const events = []
|
||||
const remote = makeRemoteSentry((detection) => {
|
||||
@@ -170,20 +184,17 @@ async function scenarioDownload() {
|
||||
}
|
||||
console.log(`[download] file received: ${got.length} bytes, content matches`)
|
||||
console.log(`[download] progress events: ${prog.length} (state->done), done(ok)=true`)
|
||||
|
||||
rmSync(srcDir, { recursive: true, force: true })
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- SCENARIO 2: upload (our engine -> remote `rz`) ----------------------------
|
||||
async function scenarioUpload() {
|
||||
console.log('\n=== 场景 2: 本地上传 + 远端 rz (upload) ===')
|
||||
const sessionId = `sess-${randomBytes(3).toString('hex')}`
|
||||
const srcDir = mkdtempSync(join(__dirname, '.zm-src-up-'))
|
||||
const srcDir = mkTemp('.zm-src-up-')
|
||||
const srcFile = join(srcDir, 'sendme.bin')
|
||||
const payload = randomBytes(150_000)
|
||||
writeFileSync(srcFile, payload)
|
||||
const outDir = mkdtempSync(join(__dirname, '.zm-out-up-'))
|
||||
const outDir = mkTemp('.zm-out-up-')
|
||||
|
||||
const events = []
|
||||
let remoteGot = null
|
||||
@@ -243,15 +254,16 @@ async function scenarioUpload() {
|
||||
}
|
||||
console.log(`[upload] remote received ${remoteGot.length} bytes, content matches`)
|
||||
console.log(`[upload] progress events: ${prog.length}, done(ok)=true`)
|
||||
|
||||
rmSync(srcDir, { recursive: true, force: true })
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- run ----------------------------------------------------------------------
|
||||
const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000)
|
||||
await scenarioDownload()
|
||||
await scenarioUpload()
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
await scenarioDownload()
|
||||
await scenarioUpload()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
cleanupTempDirs()
|
||||
}
|
||||
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
Reference in new issue
Block a user