Compare commits

...
3 Commits
Author SHA1 Message Date
Bill cff5454346 feat(workspace): 标签关闭二次确认(终端/SSH 双开关 + 键盘 Backspace/Delete 误关拦截),v1.0.27
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-15 10:35:20 +08:00
Bill bca7033689 fix(terminal): 缩放/高频输出冻结修复(reflow 节流 + 写入批量化),新增诊断日志(7 天滚动 + 开关),v1.0.26
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-11 09:52:39 +08:00
Bill fd4f1a3956 docs(roadmap): 补录通道重传缺口(exe/blockmap 409 无删除重试路径)
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-10 17:02:35 +08:00
36 changed files with 1009 additions and 59 deletions

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ tests/.ipc-channels.cjs
tests/.log-sanitizer.cjs tests/.log-sanitizer.cjs
tests/.reserved-accelerators.cjs tests/.reserved-accelerators.cjs
tests/.terminal-title.cjs tests/.terminal-title.cjs
tests/.app-log.cjs
release/ release/
# stray local test artifacts # stray local test artifacts
+12 -1
View File
@@ -44,6 +44,14 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号 5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过) 6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
## 诊断日志
- 正式版落盘的诊断日志在 `<userData>/logs/app-YYYY-MM-DD.log`(**本地日期**命名,`src/main/appLog.ts`):**保留 7 天**(今天 + 前 7 天,更早即删),启动时清扫一次 + 每 24h unref 定时器再扫(长开机的进程跨午夜也会清);只动 `app-*.log` 命名匹配的文件,目录里的外来文件不碰。会话录制日志(LOG_START)是用户文件,永远不走这里
- **开关**:`settings.system.diagnosticLog`(默认开,设置页「系统」区)。闸门在 `appLog()` 里(`setAppLogEnabled` 由 `applySystemSettings` 每次设置变更时跟随,index.ts 启动时先按 settings 设好再写启动行);渲染层看门狗发送前也自查一遍省 IPC。关闭 = 什么都不记,但保留期清扫照常(它只删不写)
- 写入方:主进程启动行 / `unhandledRejection` / `uncaughtException` / 事件循环 stall 看门狗(`index.ts`);渲染层 stall 看门狗经 `APP_LOG` IPC 中继(`main.tsx` → preload → `ipc.ts`)。`appLog()` 走异步串行队列、失败静默——**日志永远不能搞挂应用本身**;单行上限 2000 字符、内嵌换行压平、source 列定宽对齐
- 排障口径:卡死报告先让用户发当天的 app log——`[main] event loop stalled` = 主进程阻塞,`[renderer] main thread stalled` = 渲染层卡死,两条都没有 = 怀疑 GPU/外部因素
- 测试:`node tests/app-log.mjs`(经 `tests/.app-log.cjs` bundle;命名/保留窗口表驱动 + 真实临时目录的清扫、追加与开关门控)
## 更新机制 ## 更新机制
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换 - 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
@@ -68,7 +76,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 终端尺寸同步 ## 终端尺寸同步
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧 - `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效 - **fit 本身有 150ms 节流**(`src/renderer/src/terminal/fitThrottle.ts` 的 `refitDelay`,纯函数,`tests/refit-throttle.mjs` 表驱动):一次 fit 会全量 reflow 整个 scrollback(默认 5000 行,CJK 密集时几十 ms),拖动窗口每帧一次会占满主线程、饿死 xterm 写入队列,UI 在拖动结束后冻结数秒排积压。拖动期间 fit 最多每 150ms 一次,trailing 定时器在事件流停止后补一次终态 fit;`lastFitAtRef = 0` 表示「还没 fit 过」,**首次 fit 绝不等待**(PTY 以占位尺寸启动、shell 可能已在画),重绑时会重置
- 拖动窗口期间 xterm 网格按 150ms 节流更新,PTY 尺寸在停止后 100ms 生效
- **PTY 输出经 `WriteBatcher` 批量进 xterm**(`src/renderer/src/terminal/writeBatcher.ts`,`tests/write-batcher.mjs`):高频 TUI 每个 node-pty 读块一条 IPC,每条都 `term.write` 会让 xterm 解析调度器每秒重启数百次、永远追不上生产者。批量化后每 ~16ms 窗口合并成一次 write(与 carry flush 同节奏),顺序不变。两个别忘了的接线点:① `markDead` 先 `batcher.flushNow()` 再置 dead 标志——PTY_EXIT 在数据之后、flush 定时器之前到达,dead 后写入会被丢弃;② effect cleanup 里 `batcher.dispose()`(重绑的 pane 靠 session replay 补回,不丢可见内容)
## 性能与安全边界(M11 第二轮) ## 性能与安全边界(M11 第二轮)
@@ -106,6 +116,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀 - 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话 - **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
- 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater) - 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater)
- **tab 关闭二次确认**(`settings.terminal.confirmTabClose` / `confirmSshTabClose`,默认关):拦截点在 `TerminalTab.tsx` 的 `requestClose`——dockview 8.2.0 没有可否决的关闭前钩子,杀会话发生在 `onDidRemovePanel` 之后无法回滚,所以所有用户关闭入口(× 按钮、右键三关闭项、键盘 Delete/Backspace)必须在调用 `api.close()` 之前过它;键盘路径靠挂在 `.dv-tab` 外壳(自定义 div 的 dockview 祖先元素,DOM 焦点落点)上的原生捕获监听拦截。**新增用户可触发的关闭入口必须走 `requestClose`**,会话已死自动关闭/模板应用等程序性路径不走
## 关键词高亮 ## 关键词高亮
+29
View File
@@ -1,5 +1,34 @@
# OpenTerminal Changelog # OpenTerminal Changelog
## v1.0.27 - 2026-10-15
This release adds close confirmation for terminal tabs: an accidental close no longer kills a terminal or SSH session you are working in — especially the frequent case of pressing Backspace right after clicking a tab.
### New: confirm before closing tabs
- Two new switches under Settings → Rendering & Behavior (**off by default**; leaving them off keeps the previous behaviour exactly):
- **Confirm before closing terminal tabs**: ask before closing a local terminal tab
- **Confirm before closing SSH tabs**: ask before closing an SSH connection tab (SSH disconnects are costly, so you can enable it for SSH only)
- Covers every user-initiated close path: the tab × button, the context menu's Close / Close others / Close right, and the Delete / Backspace keys
- "Close others" / "Close right" ask once for the whole batch, with the number of tabs shown in the dialog
- Pressing Backspace / Delete while a tab has keyboard focus (the main source of accidental closes) no longer closes it directly — it goes through the same confirmation
- Switches take effect immediately after saving, no restart needed; typing Backspace / Delete inside the terminal is completely unaffected
- Programmatic closes (a session exiting on its own, applying a layout template) never prompt
## v1.0.26 - 2026-10-11
This release is about performance and diagnosability: it fixes the multi-second UI freezes when dragging to resize the window or when a terminal floods output, and adds an on-disk diagnostic log (on by default, auto-cleaned after 7 days) so freeze reports finally come with evidence.
### Fixed: intermittent UI freezes
- While dragging to resize, the terminal grid reflow now runs at most once every 150ms instead of every frame, with a final reflow at the settled size — dragging no longer locks up the UI
- Terminal output is now written to xterm in 16ms batches: when several high-refresh TUIs (e.g. an AI coding assistant's concurrent subagents) flood the screen at once, output no longer backlogs into a multi-second freeze
- Together, these keep tab switching, typing and pane dragging responsive under heavy load
### New: diagnostic log
- New Settings → System toggle "Diagnostic log" (**on by default**)
- Key events — startup, UI freezes (with duration and which process stalled), crashes — are written to the log directory, one file per day, **auto-deleted after 7 days**
- While off, nothing is recorded
- Attach the day's log file when reporting a problem to pinpoint the freeze source
## v1.0.25 - 2026-10-10 ## v1.0.25 - 2026-10-10
## v1.0.23 ## v1.0.23
+29
View File
@@ -1,5 +1,34 @@
# OpenTerminal 更新履歴 # OpenTerminal 更新履歴
## v1.0.27 - 2026-10-15
本リリースではタブを閉じる前の確認機能を追加しました。うっかり閉じて作業中のターミナルや SSH セッションを終了させてしまう——特にタブをクリックした直後に Backspace を押してタブごと閉じてしまう操作——を防ぎます。
### 新機能:タブを閉じる前の確認
- 設定 → 描画と動作 に 2 つのスイッチを追加(**既定はオフ**。オフのままなら従来の動作と完全に同じ):
- **ターミナルタブを閉じる前に確認**:ローカルターミナルタブを閉じる前に確認ダイアログを表示
- **SSH タブを閉じる前に確認**:SSH 接続タブを閉じる前に確認ダイアログを表示(SSH の切断はコストが高いため、SSH のみ有効にすることもできます)
- ユーザーによるすべての閉じ操作を対象:タブの × ボタン、右クリックメニューの「閉じる / ほかを閉じる / 右側を閉じる」、および Delete / Backspace キー
- 「ほかを閉じる / 右側を閉じる」はバッチ全体で 1 回だけ確認し、閉じるタブ数を表示します
- タブにキーボードフォーカスがある状態での Backspace / Delete は直接閉じず、同じ確認フローを通ります——誤操作の主な原因でした
- スイッチは保存後すぐに反映され、再起動は不要です。ターミナル内での通常の Backspace / Delete 入力には一切影響しません
- セッションの自然終了やレイアウトテンプレート適用などのプログラム的な閉じでは確認しません
## v1.0.26 - 2026-10-11
今回の重点はパフォーマンスと診断性です:ウィンドウのドラッグリサイズや端末の高頻度出力で UI 全体が数秒間フリーズする問題を修正し、診断ログの保存(デフォルト有効・7 日で自動削除)を追加しました。フリーズ報告に証拠が添えられるようになります。
### 修正:断続的な UI フリーズ
- ドラッグリサイズ中の端末グリッド再配置は「毎フレーム全量」から「最大 150ms ごと」になり、ドラッグ終了時に最終サイズへ自動調整されます。ドラッグ中に UI が固まらなくなりました
- 端末出力は 16ms ごとのバッチ書き込みに:高頻度 TUI(AI コーディングアシスタントの並行サブタスクなど)が同時に出力を流しても、数秒のフリーズにつながる滞留がなくなりました
- この 2 つにより、高負荷時のタブ切り替え・入力・ペインのドラッグが応答性を保ちます
### 新規:診断ログ
- 設定 → システムに「診断ログを記録」スイッチを追加(**デフォルト有効**)
- 起動・UI フリーズ(時間と発生プロセス付き)・クラッシュなどの主要イベントをログディレクトリに記録。日ごとのファイルで、**7 日間保持後に自動削除**
- オフの間は一切記録しません
- 問題報告の際に当日のログファイルを添付すれば、フリーズ原因を正確に特定できます
## v1.0.25 - 2026-10-10 ## v1.0.25 - 2026-10-10
## v1.0.23 ## v1.0.23
+29
View File
@@ -1,5 +1,34 @@
# OpenTerminal 更新日志 # OpenTerminal 更新日志
## v1.0.27 - 2026-10-15
本次新增标签页关闭二次确认:误触关闭不再直接杀掉正在工作的终端或 SSH 会话,尤其是点击标签后顺手按 Backspace 把整个 tab 关掉的高频误操作。
### 新增:关闭标签二次确认
- 设置 → 渲染与行为 新增两个开关(**默认关闭**,不开启则与原行为完全一致):
- **关闭终端标签二次确认**:关闭本地终端标签前弹出确认框
- **关闭 SSH 连接标签二次确认**:关闭 SSH 连接标签前弹出确认框(SSH 断开代价高,可以只对它开启)
- 覆盖所有用户关闭入口:标签 × 按钮、右键菜单的「关闭 / 关闭其他 / 关闭右侧」、以及键盘 Delete / Backspace
- 「关闭其他 / 关闭右侧」对整批只弹一次确认,确认框会显示将关闭的数量
- 点击标签获得焦点后按 Backspace / Delete 不再直接关闭标签,而是走同样的确认逻辑——这是误关闭的主要来源
- 开关保存后立即生效,无需重启;终端内正常输入退格、删除完全不受影响
- 会话自然退出、应用布局模板等程序性关闭不弹确认
## v1.0.26 - 2026-10-11
本次重点是性能与可诊断性:修复了拖动缩放窗口或终端高频输出时整个界面冻结数秒的问题;新增落盘的诊断日志(默认开启,7 天自动清理),以后反馈卡顿可以附上日志,定位不再靠猜。
### 修复:界面间歇性冻结
- 拖动缩放窗口时,终端网格重排从「每帧一次全量重排」改为最多每 150ms 一次,松手后自动落到最终尺寸——拖动过程不再把界面卡死
- 终端输出改为每 16ms 批量写入:多个高刷新 TUI(如 AI 编程助手的并发子任务)同时刷屏时,输出不再积压成数秒的界面冻结
- 上述两个修复叠加后,重负载下切标签、打字、拖动分屏都应保持可交互
### 新增:诊断日志
- 设置 → 系统新增「记录诊断日志」开关(**默认开启**)
- 启动、界面卡死(含卡顿时长与所在进程)、崩溃等关键事件写入日志目录,按天一个文件,**保留 7 天自动清理**
- 关闭后不再记录任何内容
- 反馈问题时附上当天的日志文件即可精确定位卡顿来源
## v1.0.25 - 2026-10-10 ## v1.0.25 - 2026-10-10
新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。 新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。
+29
View File
@@ -1,5 +1,34 @@
# OpenTerminal 更新日誌 # OpenTerminal 更新日誌
## v1.0.27 - 2026-10-15
本次新增分頁關閉二次確認:誤觸關閉不再直接殺掉正在工作的終端機或 SSH 連線,尤其是點擊分頁後順手按 Backspace 把整個分頁關掉的高頻誤操作。
### 新增:關閉分頁二次確認
- 設定 → 渲染與行為 新增兩個開關(**預設關閉**,不開啟則與原行為完全一致):
- **關閉終端機標籤二次確認**:關閉本地終端機分頁前彈出確認框
- **關閉 SSH 連線標籤二次確認**:關閉 SSH 連線分頁前彈出確認框(SSH 斷開代價高,可以只對它開啟)
- 涵蓋所有使用者關閉入口:分頁 × 按鈕、右鍵選單的「關閉 / 關閉其他 / 關閉右側」、以及鍵盤 Delete / Backspace
- 「關閉其他 / 關閉右側」對整批只彈一次確認,確認框會顯示將關閉的數量
- 點擊分頁取得焦點後按 Backspace / Delete 不再直接關閉分頁,而是走同樣的確認邏輯——這是誤關閉的主要來源
- 開關儲存後立即生效,無需重啟;終端機內正常輸入退格、刪除完全不受影響
- 連線自然退出、套用版面配置範本等程式性關閉不彈確認
## v1.0.26 - 2026-10-11
本次重點是效能與可診斷性:修復拖曳縮放視窗或終端機高頻輸出時整個介面凍結數秒的問題;新增落盤的診斷日誌(預設開啟,7 天自動清理),日後回報卡頓可附上月誌,定位不再靠猜。
### 修復:介面間歇性凍結
- 拖曳縮放視窗時,終端機網格重排從「每幀一次全量重排」改為最多每 150ms 一次,放開後自動落到最終尺寸——拖曳過程不再把介面卡死
- 終端機輸出改為每 16ms 批次寫入:多個高刷新 TUI(如 AI 程式助手的並發子任務)同時刷屏時,輸出不再積壓成數秒的介面凍結
- 上述兩個修復疊加後,重負載下切換分頁、打字、拖曳分割畫面都應保持可互動
### 新增:診斷日誌
- 設定 → 系統新增「記錄診斷日誌」開關(**預設開啟**)
- 啟動、介面凍結(含凍結時長與所在處理序)、當機等關鍵事件寫入日誌目錄,每日一個檔案,**保留 7 天自動清除**
- 關閉後不再記錄任何內容
- 回報問題時附上當天的日誌檔案即可精確定位卡頓來源
## v1.0.25 - 2026-10-10 ## v1.0.25 - 2026-10-10
## v1.0.23 ## v1.0.23
+1
View File
@@ -168,6 +168,7 @@
- [ ] 「启动时自动检查」运行中途拨开会在 ~5s 后立即检查(startupArmed 语义应为下次启动生效) - [ ] 「启动时自动检查」运行中途拨开会在 ~5s 后立即检查(startupArmed 语义应为下次启动生效)
- [ ] 下载无整体超时:检查有 withTimeout,下载没有——慢滴流可让 downloading 卡死且自动下载不再重试(需设计,不能简单套超时杀死慢速下载) - [ ] 下载无整体超时:检查有 withTimeout,下载没有——慢滴流可让 downloading 卡死且自动下载不再重试(需设计,不能简单套超时杀死慢速下载)
- [ ] release.cjs `--channel-only` 与 `--skip-gitea` 组合矛盾(channel-only 先 return,警告永不打印,Gitea 通道仍会上传) - [ ] release.cjs `--channel-only` 与 `--skip-gitea` 组合矛盾(channel-only 先 return,警告永不打印,Gitea 通道仍会上传)
- [ ] release.cjs 通道重传缺口:exe/blockmap 的 PUT 撞 409 直接 throw(只有 release-notes/latest.yml 有「409→删除→重传」路径),同版本替换资产时必须先手动 DELETE 通道文件
- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制) - [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制)
- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令) - [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等) - [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.25", "version": "1.0.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.25", "version": "1.0.27",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15", "@lydell/node-pty": "^1.2.0-beta.15",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.25", "version": "1.0.27",
"description": "Open-source terminal with SSH, split panes, themes and fonts", "description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "CodingPlan.Site", "author": "CodingPlan.Site",
@@ -13,7 +13,7 @@
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"pretest": "npm run typecheck", "pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs", "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only", "predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win nsis", "dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir" "dist:dir": "electron-vite build && electron-builder --win --dir"
+121
View File
@@ -0,0 +1,121 @@
/**
* Diagnostic app log (appLog.ts).
*
* The "app froze, then recovered" watchdogs (`[main]` / `[renderer] stalled`)
* used to reach only the console, which a packaged build never shows — field
* reports came back with no evidence attached. This module persists them:
* daily files `app-YYYY-MM-DD.log` under `<userData>/logs`, appended
* asynchronously off the event loop's critical path, one event per line.
*
* Retention: files older than APP_LOG_RETENTION_DAYS are deleted at startup
* and once a day afterwards (a long-running process crosses midnights). Only
* names matching the app log pattern are touched — foreign files in the
* directory are left alone. Session recordings (LOG_START) are user files
* and never come near this mechanism.
*
* The pure naming/retention helpers are table-tested by tests/app-log.mjs
* through the bundled build (tests/.app-log.cjs).
*/
import { app } from 'electron'
import { appendFile, mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
export const APP_LOG_RETENTION_DAYS = 7
const NAME_RE = /^app-(\d{4})-(\d{2})-(\d{2})\.log$/
const DAY_MS = 24 * 60 * 60 * 1000
/** One event is one line; runaway payloads are truncated, not trusted. */
const MAX_LINE = 2000
const pad = (n: number, w = 2): string => String(n).padStart(w, '0')
/** `app-YYYY-MM-DD.log`, on the LOCAL date — users read these by hand. */
export function appLogName(date: Date): string {
return `app-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.log`
}
/** Local midnight (ms) encoded in an app log file name, or null for foreign names. */
export function parseAppLogName(name: string): number | null {
const m = NAME_RE.exec(name)
if (!m) return null
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
}
/**
* A log file expires when its day STARTS more than `retentionDays` before
* today's start: today plus the previous N days are kept, anything older
* goes. Foreign names are never expired.
*/
export function isExpiredAppLog(name: string, now: Date, retentionDays = APP_LOG_RETENTION_DAYS): boolean {
const day = parseAppLogName(name)
if (day === null) return false
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
return day < today - retentionDays * DAY_MS
}
let logDir: string | null = null
let queue: Promise<unknown> = Promise.resolve()
// The settings gate (settings.system.diagnosticLog, default on). Off = appLog
// records nothing; the retention sweep still runs, it only deletes.
let enabled = true
/** Follow `settings.system.diagnosticLog`; called on every settings mutation. */
export function setAppLogEnabled(on: boolean): void {
enabled = on
}
function enqueue(task: () => Promise<unknown>): Promise<void> {
// Serialize appends, and never let a logging failure escape into the app.
const next = queue.then(task).then(
(): void => undefined,
(): void => undefined
)
queue = next
return next
}
/**
* Point the log at `<userData>/logs` (or `dir` in tests), sweep expired
* files, and re-sweep once a day so a process that never restarts still
* cleans up. The daily timer is unref'd like every other timer in main.
*/
export function initAppLog(dir?: string): void {
const target = dir ?? join(app.getPath('userData'), 'logs')
logDir = target
void enqueue(async () => {
await mkdir(target, { recursive: true })
await sweepAppLogs(target)
})
setInterval(() => {
void sweepAppLogs(target).catch(() => undefined)
}, DAY_MS).unref()
}
/** Remove expired log files under `dir`; returns the removed names. */
export async function sweepAppLogs(
dir: string,
now = new Date(),
retentionDays = APP_LOG_RETENTION_DAYS
): Promise<string[]> {
const entries = await readdir(dir)
const doomed = entries.filter((n) => isExpiredAppLog(n, now, retentionDays))
await Promise.all(doomed.map((n) => rm(join(dir, n), { force: true })))
return doomed
}
const stamp = (d: Date): string =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
/**
* Append one event line. Fire-and-forget for callers, but the returned
* promise settles once the line is on disk (tests drain on it). Before
* initAppLog — or with the setting gate off — the line is dropped, same as
* the console-only past. The source column is padded so stacked lines align.
*/
export function appLog(source: 'main' | 'renderer', line: string): Promise<void> {
if (!logDir || !enabled) return Promise.resolve()
const dir = logDir
const text = `[${stamp(new Date())}] [${source.padEnd(8)}] ${String(line).replace(/[\r\n]+/g, ' ').slice(0, MAX_LINE)}\n`
return enqueue(() => appendFile(join(dir, appLogName(new Date())), text, 'utf8'))
}
+16 -1
View File
@@ -11,6 +11,7 @@ import { applyMenuLockState } from './lockMenu'
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts' import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray' import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater' import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater'
import { appLog, initAppLog, setAppLogEnabled } from './appLog'
import { applyWindowChrome } from './windowChrome' import { applyWindowChrome } from './windowChrome'
import { onLanguageChange, t } from '@shared/i18n' import { onLanguageChange, t } from '@shared/i18n'
import { getThemeById } from '@shared/theme' import { getThemeById } from '@shared/theme'
@@ -72,6 +73,13 @@ if (!gotSingleInstanceLock) {
app app
.whenReady() .whenReady()
.then(() => { .then(() => {
// The diagnostic log must exist before any IPC handler can reference it;
// startup also sweeps log files past the retention window. The gate is
// set from settings first, so even the startup line respects the switch.
setAppLogEnabled(loadSettings().system.diagnosticLog !== false)
initAppLog()
void appLog('main', `app started: v${app.getVersion()} (${process.platform}/${process.arch})`)
// Serve the configured background image (path lives in settings; anything // Serve the configured background image (path lives in settings; anything
// else — including a path that is no longer configured — is refused, so the // else — including a path that is no longer configured — is refused, so the
// protocol cannot be used to read arbitrary files). // protocol cannot be used to read arbitrary files).
@@ -263,20 +271,27 @@ function createWindow(): void {
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
console.error('[main] unhandledRejection:', reason) console.error('[main] unhandledRejection:', reason)
void appLog('main', `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`)
}) })
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
console.error('[main] uncaughtException:', err) console.error('[main] uncaughtException:', err)
void appLog('main', `uncaughtException: ${err.stack ?? err.message}`)
}) })
// Diagnostics for "app froze then recovered" reports: WER logs an // Diagnostics for "app froze then recovered" reports: WER logs an
// AppHangTransient but keeps no stack. A stall of THIS (UI) thread is what // AppHangTransient but keeps no stack. A stall of THIS (UI) thread is what
// Windows flags as a hang, so measure event-loop lag and say so loudly when // Windows flags as a hang, so measure event-loop lag and say so loudly when
// it happens — that distinguishes a main-process block from a renderer one. // it happens — that distinguishes a main-process block from a renderer one.
// The same line goes to the on-disk diagnostic log (appLog.ts), which a
// packaged build's console cannot reach.
let lastTick = Date.now() let lastTick = Date.now()
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
const lag = now - lastTick - 1000 const lag = now - lastTick - 1000
if (lag > 2000) console.error(`[main] event loop stalled ~${lag}ms`) if (lag > 2000) {
console.error(`[main] event loop stalled ~${lag}ms`)
void appLog('main', `event loop stalled ~${lag}ms`)
}
lastTick = now lastTick = now
}, 1000).unref() }, 1000).unref()
+5
View File
@@ -36,6 +36,7 @@ import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/commands' import type { CommandItem } from '../shared/commands'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty' import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty'
import { respondZmodem } from './zmodem' import { respondZmodem } from './zmodem'
import { appLog } from './appLog'
import type { ZmodemResponse } from '../shared/ipc' import type { ZmodemResponse } from '../shared/ipc'
/** The renderer document this app loads (dev builds load it from vite instead). */ /** The renderer document this app loads (dev builds load it from vite instead). */
@@ -149,6 +150,10 @@ export function registerIpc(): void {
Ipc.APP_INFO, Ipc.APP_INFO,
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() }) (): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
) )
// Renderer watchdog reports land in the same diagnostic log as main's own.
ipcMain.on(Ipc.APP_LOG, (_event, line: string) => {
if (typeof line === 'string') void appLog('renderer', line)
})
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id)) 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_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
+5
View File
@@ -22,6 +22,7 @@ import { broadcast } from './broadcast'
import { writeJson } from './store' import { writeJson } from './store'
import { applyGlobalShortcut } from './globalShortcuts' import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome' import { applyWindowChrome } from './windowChrome'
import { setAppLogEnabled } from './appLog'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json') const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
@@ -372,6 +373,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
autoDownloadUpdate: candidate.autoDownloadUpdate === true, autoDownloadUpdate: candidate.autoDownloadUpdate === true,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen". // Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false, restoreSession: candidate.restoreSession !== false,
diagnosticLog: candidate.diagnosticLog !== false,
shellIntegration: candidate.shellIntegration === true, shellIntegration: candidate.shellIntegration === true,
language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE
} }
@@ -442,6 +444,9 @@ function applySystemSettings(system: SystemSettings): void {
} }
// Re-register the global show/hide shortcut whenever system settings change. // Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide) applyGlobalShortcut(system.globalShowHide)
// The diagnostic log gate follows the setting live (appLog is imported here —
// it only touches electron/fs, so there is no cycle).
setAppLogEnabled(system.diagnosticLog !== false)
// Re-arm (or disarm) the periodic update check the same way. Routed through the // Re-arm (or disarm) the periodic update check the same way. Routed through the
// injected applier instead of importing the updater, which would be a cycle. // injected applier instead of importing the updater, which would be a cycle.
updateScheduleApplier?.(system) updateScheduleApplier?.(system)
+1
View File
@@ -15,6 +15,7 @@ import type { TransferProgressEvent } from '../shared/sftp'
const api: AppApi = { const api: AppApi = {
appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO), appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO),
appLog: (line: string) => ipcRenderer.send(Ipc.APP_LOG, line),
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
+11 -2
View File
@@ -14,7 +14,9 @@ import './global.css'
// Diagnostics: report renderer main-thread freezes after recovery. console.error // Diagnostics: report renderer main-thread freezes after recovery. console.error
// is surfaced into the dev terminal by the main process's console-message hook, // is surfaced into the dev terminal by the main process's console-message hook,
// so a "卡死又恢复" report tells us which process stalled and for how long. // and the same line goes through APP_LOG into the on-disk diagnostic log, so a
// packaged build's "卡死又恢复" report also tells us which process stalled and
// for how long.
{ {
let lastTick = performance.now() let lastTick = performance.now()
setInterval(() => { setInterval(() => {
@@ -26,7 +28,13 @@ import './global.css'
// tray. A hidden window also has nothing user-visible to freeze, so the // tray. A hidden window also has nothing user-visible to freeze, so the
// report is skipped until the page is visible again. // report is skipped until the page is visible again.
if (lag > 3000 && !document.hidden) { if (lag > 3000 && !document.hidden) {
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`) const line = `main thread stalled ~${Math.round(lag)}ms`
console.error(`[renderer] ${line}`)
// The settings gate is enforced again in main; checking here just saves
// the IPC round-trip for a stalled-process report nobody wants stored.
if (useSettingsStore.getState().settings.system.diagnosticLog !== false) {
window.api?.appLog?.(line)
}
} }
lastTick = now lastTick = now
}, 2000) }, 2000)
@@ -49,6 +57,7 @@ if (typeof window !== 'undefined' && !window.api) {
}) })
const api: AppApi = { const api: AppApi = {
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }), appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
appLog: noop,
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
openSession: async () => ({ id: stubId() }), openSession: async () => ({ id: stubId() }),
getSessionReplay: async () => '', getSessionReplay: async () => '',
@@ -275,6 +275,26 @@ export function RenderSettingsTab(): React.JSX.Element {
/> />
} }
/> />
<SettingRow
label={t('settings.render.confirmTabClose')}
desc={t('settings.render.confirmTabCloseDesc')}
control={
<Switch
checked={settings.terminal.confirmTabClose}
onChange={(checked) => void updateTerminal({ confirmTabClose: checked })}
/>
}
/>
<SettingRow
label={t('settings.render.confirmSshTabClose')}
desc={t('settings.render.confirmSshTabCloseDesc')}
control={
<Switch
checked={settings.terminal.confirmSshTabClose}
onChange={(checked) => void updateTerminal({ confirmSshTabClose: checked })}
/>
}
/>
<SettingRow <SettingRow
label={t('settings.render.suggest')} label={t('settings.render.suggest')}
desc={t('settings.render.suggestDesc')} desc={t('settings.render.suggestDesc')}
@@ -416,6 +436,16 @@ export function SystemSettingsTab(): React.JSX.Element {
/> />
} }
/> />
<SettingRow
label={t('settings.system.diagnosticLog')}
desc={t('settings.system.diagnosticLogDesc')}
control={
<Switch
checked={settings.system.diagnosticLog !== false}
onChange={(checked) => void updateSystem({ diagnosticLog: checked })}
/>
}
/>
<SettingRow <SettingRow
label={t('settings.system.updateCheckInterval')} label={t('settings.system.updateCheckInterval')}
desc={t('settings.system.updateCheckIntervalDesc')} desc={t('settings.system.updateCheckIntervalDesc')}
+53 -2
View File
@@ -19,6 +19,8 @@ import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats' import { publishHighlightStats } from './highlightStats'
import { applyThemeColors } from '@renderer/theme/highlightColors' import { applyThemeColors } from '@renderer/theme/highlightColors'
import { findUrls } from './urlLinks' import { findUrls } from './urlLinks'
import { WriteBatcher } from './writeBatcher'
import { refitDelay } from './fitThrottle'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher' import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker' import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore' import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -348,6 +350,12 @@ export function TerminalView({
const searchRef = useRef<SearchAddon | null>(null) const searchRef = useRef<SearchAddon | null>(null)
const observerRef = useRef<ResizeObserver | null>(null) const observerRef = useRef<ResizeObserver | null>(null)
const timerRef = useRef<number>(0) const timerRef = useRef<number>(0)
// Reflow throttle: `lastFitAtRef` is the last fit attempt's timestamp (0 =
// none yet, so a rebound session's first fit runs immediately),
// `fitTrailingRef` is the trailing timer that guarantees a final fit at the
// settled size once a drag's resize-event stream stops.
const lastFitAtRef = useRef(0)
const fitTrailingRef = useRef<number>(0)
// PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window // PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window
// drag/maximize settles, `ptySizeRef` drops the no-op resizes in between. // drag/maximize settles, `ptySizeRef` drops the no-op resizes in between.
// Poking the pty on every intermediate layout tick makes full-screen TUIs // Poking the pty on every intermediate layout tick makes full-screen TUIs
@@ -518,6 +526,24 @@ export function TerminalView({
const scheduleFit = useCallback(() => { const scheduleFit = useCallback(() => {
window.cancelAnimationFrame(timerRef.current) window.cancelAnimationFrame(timerRef.current)
timerRef.current = window.requestAnimationFrame(() => { timerRef.current = window.requestAnimationFrame(() => {
// Throttle reflows while a window drag resizes the pane every frame: one
// fit rewraps the whole scrollback (tens of ms at the default 5000
// lines, more with CJK), and one per frame saturates the main thread and
// starves xterm's write queue — the UI then stays frozen for seconds
// after the drag while the backlog drains. During a drag fits run at
// most every FIT_REFLOW_THROTTLE_MS; the trailing timer lands a final
// fit at the settled size.
const wait = refitDelay(lastFitAtRef.current, performance.now())
if (wait > 0) {
if (!fitTrailingRef.current) {
fitTrailingRef.current = window.setTimeout(() => {
fitTrailingRef.current = 0
scheduleFit()
}, wait)
}
return
}
lastFitAtRef.current = performance.now()
const fit = fitAddonRef.current const fit = fitAddonRef.current
if (!fit) return if (!fit) return
try { try {
@@ -907,6 +933,10 @@ export function TerminalView({
// from a template apply or panel reuse; recording resumes idle. // from a template apply or panel reuse; recording resumes idle.
lineBufRef.current = '' lineBufRef.current = ''
ptySizeRef.current = null ptySizeRef.current = null
// A rebound session's first fit must not inherit the previous session's
// throttle window — the pty spawns at a placeholder size and the shell
// may already be drawing.
lastFitAtRef.current = 0
setSuggestions([]) setSuggestions([])
setSuggestionIndex(0) setSuggestionIndex(0)
if (recordingRef.current) { if (recordingRef.current) {
@@ -928,18 +958,28 @@ export function TerminalView({
// A shell prompt is exactly such a run with no follow-up data, so flush the // A shell prompt is exactly such a run with no follow-up data, so flush the
// carry on a short timer — prompts render within a frame, anchors survive. // carry on a short timer — prompts render within a frame, anchors survive.
let carryTimer: number | undefined let carryTimer: number | undefined
// Batch xterm writes: a busy TUI delivers one IPC message per pty read,
// and every term.write re-arms xterm's parser scheduler — hundreds of
// tiny writes a second keep the parser permanently behind the producer,
// which is what froze the UI for seconds after an output burst. The
// batcher hands output over in one write per ~16ms window (the carry
// cadence), so a flood costs one parser run per frame instead of one per
// IPC message.
const batcher = new WriteBatcher((text) => {
if (!deadRef.current) term.write(text)
})
const scheduleCarryFlush = (): void => { const scheduleCarryFlush = (): void => {
if (carryTimer !== undefined || !stream.hasPending()) return if (carryTimer !== undefined || !stream.hasPending()) return
carryTimer = window.setTimeout(() => { carryTimer = window.setTimeout(() => {
carryTimer = undefined carryTimer = undefined
if (deadRef.current) return if (deadRef.current) return
const rest = stream.flush() const rest = stream.flush()
if (rest) term.write(rest) if (rest) batcher.push(rest)
}, 16) }, 16)
} }
const writeHighlighted = (data: string) => { const writeHighlighted = (data: string) => {
const out = stream.push(data) const out = stream.push(data)
if (out) term.write(out) if (out) batcher.push(out)
scheduleCarryFlush() scheduleCarryFlush()
} }
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser; // Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
@@ -1076,6 +1116,10 @@ export function TerminalView({
// Death state, shared by the PTY_EXIT subscription and the SESSION_STATE // Death state, shared by the PTY_EXIT subscription and the SESSION_STATE
// query below: the two paths must land identically. // query below: the two paths must land identically.
const markDead = (code: number): void => { const markDead = (code: number): void => {
// The batcher may still hold the session's final output (PTY_EXIT lands
// after the data but before the flush timer); release it before the
// dead flag starts dropping writes.
batcher.flushNow()
deadRef.current = true deadRef.current = true
setExitCode(code) setExitCode(code)
setDead(true) setDead(true)
@@ -1158,6 +1202,13 @@ export function TerminalView({
return () => { return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer) if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current) if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
if (fitTrailingRef.current) {
window.clearTimeout(fitTrailingRef.current)
fitTrailingRef.current = 0
}
// Teardown/rebind drops whatever the batcher still holds; a rebound
// pane re-fetches the session replay, so nothing visible is lost.
batcher.dispose()
host?.removeEventListener('keydown', onPasteKey, true) host?.removeEventListener('keydown', onPasteKey, true)
for (const unsubscribe of unsubscribes) unsubscribe() for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
+28
View File
@@ -0,0 +1,28 @@
/**
* Refit throttle (fitThrottle.ts).
*
* `FitAddon.fit()` triggers an xterm resize, which reflows the WHOLE
* scrollback — tens of milliseconds at the default 5000 lines, more with
* CJK-dense buffers. A window drag fires the pane's ResizeObserver every
* animation frame, so an unthrottled fit-per-frame saturates the main
* thread and starves xterm's write queue; the UI then stays frozen for
* seconds after the drag while the backlog drains. The throttle caps
* reflows during a drag; a trailing timer in TerminalView lands the final
* fit at the settled size.
*
* Pure decision function (the rAF/trailing-timer wiring stays in
* TerminalView), table-tested by tests/refit-throttle.mjs.
*/
export const FIT_REFLOW_THROTTLE_MS = 150
/**
* Milliseconds to wait before the next fit may reflow; 0 = fit now.
* `lastFitAt <= 0` marks "no fit recorded yet" — a session's first fit must
* never wait, because the pty spawns at a placeholder size and the shell may
* already be drawing. `now` comes from a monotonic clock (performance.now).
*/
export function refitDelay(lastFitAt: number, now: number, throttleMs: number = FIT_REFLOW_THROTTLE_MS): number {
if (lastFitAt <= 0) return 0
const wait = throttleMs - (now - lastFitAt)
return wait > 0 ? wait : 0
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Output write batching (writeBatcher.ts).
*
* A busy TUI delivers one IPC message per pty read, and every `term.write`
* re-arms xterm's parser scheduler — hundreds of tiny writes a second keep
* the parser permanently behind the producer, which is what freezes the UI
* for seconds after an output burst while the backlog drains. This batcher
* accumulates flushed output and hands it to xterm in ONE write per ~16ms
* window, so a flood costs one parser run per frame instead of one per IPC
* message. Ordering is preserved: pushes concatenate and flush in arrival
* order.
*
* Pure + timer-only (no electron/xterm surface), so the self-test drives it
* with real timers at a small delay: tests/write-batcher.mjs.
*/
export class WriteBatcher {
private buf = ''
private timer: ReturnType<typeof setTimeout> | undefined
private readonly sink: (text: string) => void
private readonly delayMs: number
constructor(sink: (text: string) => void, delayMs = 16) {
this.sink = sink
this.delayMs = delayMs
}
/** True while buffered text is waiting for the flush timer. */
hasPending(): boolean {
return this.buf.length > 0
}
/** Append `text`; the first push in a window arms the single flush timer. */
push(text: string): void {
if (!text) return
this.buf += text
if (this.timer === undefined) {
this.timer = setTimeout(() => this.flushNow(), this.delayMs)
}
}
/**
* Emit everything buffered right now (no-op when empty). Used by the timer,
* and by the session-death path: PTY_EXIT lands after the final data but
* before the flush timer, and a dead session drops writes — so the exit
* handler flushes first.
*/
flushNow(): void {
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
if (!this.buf) return
const out = this.buf
this.buf = ''
this.sink(out)
}
/**
* Drop buffered text and cancel the timer (session teardown / rebind). A
* rebound pane re-fetches the session replay, so nothing visible is lost.
*/
dispose(): void {
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
this.buf = ''
}
}
+150 -47
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { App, Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' import { App, Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { CloudServerOutlined } from '@ant-design/icons' import { CloudServerOutlined } from '@ant-design/icons'
@@ -22,13 +22,18 @@ export type TerminalTabProps = IDockviewPanelHeaderProps<{
* 关闭 / 关闭其他 / 关闭右侧 * 关闭 / 关闭其他 / 关闭右侧
* *
* SSH sessions get a 🖥 server icon and a green left-border accent so they're * SSH sessions get a 🖥 server icon and a green left-border accent so they're
* visually distinct from local terminals. All menu actions close panels * visually distinct from local terminals. Every user-initiated close button,
* directly through `api.close()`. The workspace's `onDidRemovePanel` handles * menu action and the tablist's Delete/Backspace accelerator goes through
* the pty kill, so nothing leaks. * `requestClose`, which may ask for confirmation first (see below). Whatever
* the path, the workspace's `onDidRemovePanel` handles the pty kill, so
* nothing leaks.
*/ */
export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element { export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element {
const panel = api.group // bound panel via the panel api const panel = api.group // bound panel via the panel api
type TabPanel = (typeof panel.panels)[number]
const [title, setTitle] = useState<string | undefined>(api.title) const [title, setTitle] = useState<string | undefined>(api.title)
const [pending, setPending] = useState<TabPanel[] | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const selfIndex = panel.panels.findIndex((p) => p.api === api) const selfIndex = panel.panels.findIndex((p) => p.api === api)
// dockview keeps the tab component in its own render slot — a re-render of // dockview keeps the tab component in its own render slot — a re-render of
@@ -56,10 +61,86 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
return () => disposable.dispose() return () => disposable.dispose()
}, [api]) }, [api])
const closePanel = (target: typeof panel.panels[number]): void => { // `api.group` is read live rather than reusing the render-time `panel`:
target.api.close() // dragging this tab into another group re-parents the DOM without re-mounting
// the React component (dockview keeps the portal element and only swaps the
// `.dv-tab` shell around it), so anything captured at render would still name
// the group this tab has left.
const selfPanel = (): TabPanel | undefined => api.group.panels.find((p) => p.api === api)
// Confirmation has to happen *before* `api.close()`: dockview has no
// vetoable close hook, and the workspace kills the session from
// `onDidRemovePanel`, which is already too late to ask. Every user-initiated
// close entry point funnels through here; programmatic closes (a dead
// session, applying a layout template) call `api.close()` directly.
//
// Read the settings at call time: this runs from long-lived menu / keydown
// handlers, so a value captured at render would go stale after a change.
const needsConfirmFor = (targets: TabPanel[]): boolean => {
const terminal = useSettingsStore.getState().settings.terminal
return targets.some((p) =>
(p.params as { sessionKind?: 'local' | 'ssh' } | undefined)?.sessionKind === 'ssh'
? terminal.confirmSshTabClose
: terminal.confirmTabClose
)
} }
const requestClose = (targets: TabPanel[]): void => {
if (targets.length === 0) return
if (!needsConfirmFor(targets)) {
for (const target of targets) target.api.close()
return
}
setPending(targets)
}
const closePending = (): void => {
if (!pending) return
// Liveness filter: something else (a dead session, another tab's menu) may
// have closed a target while the dialog was up. Asked of the panel itself —
// its `group` follows it across groups, unlike a render-time snapshot.
for (const target of pending) if (target.group.panels.includes(target)) target.api.close()
setPending(null)
}
// dockview closes the focused tab on Delete/Backspace from a keydown listener
// on the tab list, but only when the event target *is* the `.dv-tab` shell —
// which is what has focus, and an ancestor of this component's root, so a
// React handler on the root never sees the key. The listener therefore has to
// be native, on the shell, in the capture phase (dockview listens during the
// bubble phase). It is attached to the document rather than to the shell
// itself because dragging a tab into another group re-parents this component's
// DOM into a fresh shell *without* re-mounting or re-rendering it: a
// shell-scoped listener would be stranded on the discarded element and
// Delete/Backspace would silently stop working for that tab. Everything the
// handler reads is resolved live (`api.group`, the settings), so the closure
// from the first render stays correct.
//
// The event is only swallowed when a confirmation is actually required;
// otherwise dockview's own handler runs as before, focus moved to the
// neighbouring tab included, so "off" is indistinguishable from the old
// behaviour.
useEffect(() => {
const doc = rootRef.current?.ownerDocument ?? document
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Delete' && event.key !== 'Backspace') return
const shell = rootRef.current?.closest('.dv-tab')
if (!shell || event.target !== shell) return
// Only the real tab strip: the overflow dropdown renders a second
// `.dv-tab`-classed wrapper around a copy of this component, and dockview's
// own handler is scoped to the tab list, so match that scope instead of
// closing a panel the platform would have left alone.
if (!shell.closest('.dv-tabs-container')) return
const self = selfPanel()
if (!self || !needsConfirmFor([self])) return
event.preventDefault()
event.stopPropagation()
requestClose([self])
}
doc.addEventListener('keydown', onKeyDown, true)
return () => doc.removeEventListener('keydown', onKeyDown, true)
}, [api])
const menu: MenuProps = { const menu: MenuProps = {
items: [ items: [
{ key: 'close', label: tFor(language, 'common.close') }, { key: 'close', label: tFor(language, 'common.close') },
@@ -67,56 +148,78 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
{ key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } { key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'close') { const self = selfPanel()
if (!self) {
api.close() api.close()
return
}
// `api.group.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. Read through `api.group`
// rather than a render-time copy in case the tab has since been dragged
// into another group.
const siblings = [...api.group.panels]
if (key === 'close') {
requestClose([self])
} else if (key === 'closeOthers') { } else if (key === 'closeOthers') {
// `panel.panels` is the live group array and `close()` splices it, so requestClose(siblings.filter((sibling) => sibling.api !== api))
// 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') { } else if (key === 'closeRight') {
for (const target of [...panel.panels].slice(selfIndex + 1)) { requestClose(siblings.slice(siblings.indexOf(self) + 1))
closePanel(target)
}
} }
} }
} }
return ( return (
<Dropdown menu={menu} trigger={['contextMenu']}> <>
<div <Dropdown menu={menu} trigger={['contextMenu']}>
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`} <div
title={tooltip} ref={rootRef}
> className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />} title={tooltip}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
api.close()
}}
> >
<CloseGlyph /> {isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
</button> <span className="workspace-terminal-tab-title">{title}</span>
</div> {isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
</Dropdown> {isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const self = selfPanel()
if (self) requestClose([self])
}}
>
<CloseGlyph />
</button>
</div>
</Dropdown>
<Modal
open={pending !== null}
title={tFor(language, 'workspace.tab.closeConfirmTitle')}
okText={tFor(language, 'workspace.tab.closeConfirmOk')}
okButtonProps={{ danger: true }}
cancelText={tFor(language, 'common.cancel')}
maskClosable={false}
onOk={closePending}
onCancel={() => setPending(null)}
>
{pending && pending.length === 1
? tFor(language, 'workspace.tab.closeConfirmOne', { title: pending[0].title ?? '' })
: tFor(language, 'workspace.tab.closeConfirmMany', { count: pending?.length ?? 0 })}
</Modal>
</>
) )
} }
+2
View File
@@ -30,6 +30,8 @@ import type { ZmodemDoneEvent, ZmodemOfferEvent, ZmodemResponse } from './ipc'
*/ */
export interface AppApi { export interface AppApi {
appInfo(): Promise<AppInfo> appInfo(): Promise<AppInfo>
/** append one line to the diagnostic app log (watchdog reports) */
appLog(line: string): void
// ---- sessions (pty data plane is shared by local and ssh sessions) ---- // ---- sessions (pty data plane is shared by local and ssh sessions) ----
createPty(opts?: PtyCreateOptions): Promise<PtyCreateResult> createPty(opts?: PtyCreateOptions): Promise<PtyCreateResult>
+9
View File
@@ -74,6 +74,12 @@ const settings: Record<string, string> = {
'settings.render.pasteRiskConfirm': 'Confirm risky paste', 'settings.render.pasteRiskConfirm': 'Confirm risky paste',
'settings.render.pasteRiskConfirmDesc': 'settings.render.pasteRiskConfirmDesc':
'Ask before pasting multi-line text (or a very long single line); single lines paste directly', 'Ask before pasting multi-line text (or a very long single line); single lines paste directly',
'settings.render.confirmTabClose': 'Confirm before closing terminal tabs',
'settings.render.confirmTabCloseDesc':
'Ask for confirmation before closing a local terminal tab',
'settings.render.confirmSshTabClose': 'Confirm before closing SSH tabs',
'settings.render.confirmSshTabCloseDesc':
'Ask for confirmation before closing an SSH connection tab',
'settings.render.suggest': 'Input suggestions', 'settings.render.suggest': 'Input suggestions',
'settings.render.suggestDesc': 'settings.render.suggestDesc':
'Show command suggestions while typing; Tab accepts, Enter always runs the command right away', 'Show command suggestions while typing; Tab accepts, Enter always runs the command right away',
@@ -110,6 +116,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell integration', 'settings.system.shellIntegration': 'Shell integration',
'settings.system.shellIntegrationDesc': 'settings.system.shellIntegrationDesc':
'Let the shell report its working directory (OSC 7) for more precise path tracking, including cd inside scripts; it wraps your prompt, so turn it off if the prompt theme looks wrong', 'Let the shell report its working directory (OSC 7) for more precise path tracking, including cd inside scripts; it wraps your prompt, so turn it off if the prompt theme looks wrong',
'settings.system.diagnosticLog': 'Diagnostic log',
'settings.system.diagnosticLogDesc':
'Write key events (startup, UI freezes, crashes) to the log directory — one file per day, auto-deleted after 7 days; nothing is recorded while this is off',
'settings.system.closeAction': 'Close button behavior', 'settings.system.closeAction': 'Close button behavior',
'settings.system.closeActionDesc': 'settings.system.closeActionDesc':
'What happens when you click the window close button; after minimizing to the tray you can quit from the tray icon context menu', 'What happens when you click the window close button; after minimizing to the tray you can quit from the tray icon context menu',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'workspace.tab.localTerminal': 'Local terminal', 'workspace.tab.localTerminal': 'Local terminal',
'workspace.tab.closeOthers': 'Close others', 'workspace.tab.closeOthers': 'Close others',
'workspace.tab.closeRight': 'Close to the right', 'workspace.tab.closeRight': 'Close to the right',
'workspace.tab.closeConfirmTitle': 'Close tab?',
'workspace.tab.closeConfirmOne': 'Close "{title}"? Its session will be terminated.',
'workspace.tab.closeConfirmMany': 'Close {count} tabs? Their sessions will be terminated.',
'workspace.tab.closeConfirmOk': 'Close',
'workspace.tab.broadcastTarget': 'Broadcast target', 'workspace.tab.broadcastTarget': 'Broadcast target',
'workspace.tab.broadcastBadge': 'B', 'workspace.tab.broadcastBadge': 'B',
'workspace.tab.addInSplit': 'New terminal in this split', 'workspace.tab.addInSplit': 'New terminal in this split',
+9
View File
@@ -72,6 +72,12 @@ const settings: Record<string, string> = {
'settings.render.pasteRiskConfirm': '貼り付け前の確認', 'settings.render.pasteRiskConfirm': '貼り付け前の確認',
'settings.render.pasteRiskConfirmDesc': 'settings.render.pasteRiskConfirmDesc':
'複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます', '複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます',
'settings.render.confirmTabClose': 'ターミナルタブを閉じる前に確認',
'settings.render.confirmTabCloseDesc':
'ローカルターミナルタブを閉じる前に確認ダイアログを表示',
'settings.render.confirmSshTabClose': 'SSH タブを閉じる前に確認',
'settings.render.confirmSshTabCloseDesc':
'SSH 接続タブを閉じる前に確認ダイアログを表示',
'settings.render.suggest': '入力候補', 'settings.render.suggest': '入力候補',
'settings.render.suggestDesc': 'settings.render.suggestDesc':
'入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します', '入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します',
@@ -107,6 +113,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 統合', 'settings.system.shellIntegration': 'Shell 統合',
'settings.system.shellIntegrationDesc': 'settings.system.shellIntegrationDesc':
'shell に作業ディレクトリを報告させ(OSC 7)、パスの記憶をより正確にします(スクリプト内の cd も含みます)。プロンプトをラップするため、プロンプトの表示が崩れる場合はオフにしてください', 'shell に作業ディレクトリを報告させ(OSC 7)、パスの記憶をより正確にします(スクリプト内の cd も含みます)。プロンプトをラップするため、プロンプトの表示が崩れる場合はオフにしてください',
'settings.system.diagnosticLog': '診断ログを記録',
'settings.system.diagnosticLogDesc':
'起動・UI フリーズ・クラッシュなどの主要イベントをログディレクトリに書き込みます(日ごとのファイル、7 日間保持で自動削除)。オフの間は記録しません',
'settings.system.closeAction': '閉じるボタンの動作', 'settings.system.closeAction': '閉じるボタンの動作',
'settings.system.closeActionDesc': 'settings.system.closeActionDesc':
'ウィンドウの閉じるボタンを押したときの動作。タスクトレイに最小化した後は、トレイアイコンの右クリックメニューから終了できます', 'ウィンドウの閉じるボタンを押したときの動作。タスクトレイに最小化した後は、トレイアイコンの右クリックメニューから終了できます',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'workspace.tab.localTerminal': 'ローカルターミナル', 'workspace.tab.localTerminal': 'ローカルターミナル',
'workspace.tab.closeOthers': 'ほかを閉じる', 'workspace.tab.closeOthers': 'ほかを閉じる',
'workspace.tab.closeRight': '右側を閉じる', 'workspace.tab.closeRight': '右側を閉じる',
'workspace.tab.closeConfirmTitle': 'タブを閉じますか?',
'workspace.tab.closeConfirmOne': '「{title}」を閉じますか?対応するセッションは終了します。',
'workspace.tab.closeConfirmMany': '{count} 個のタブを閉じますか?対応するセッションは終了します。',
'workspace.tab.closeConfirmOk': '閉じる',
'workspace.tab.broadcastTarget': 'ブロードキャスト対象', 'workspace.tab.broadcastTarget': 'ブロードキャスト対象',
'workspace.tab.broadcastBadge': '配', 'workspace.tab.broadcastBadge': '配',
'workspace.tab.addInSplit': 'この分割に新しいターミナル', 'workspace.tab.addInSplit': 'この分割に新しいターミナル',
+7
View File
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制', 'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制',
'settings.render.pasteRiskConfirm': '粘贴风险确认', 'settings.render.pasteRiskConfirm': '粘贴风险确认',
'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴', 'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴',
'settings.render.confirmTabClose': '关闭终端标签二次确认',
'settings.render.confirmTabCloseDesc': '关闭本地终端标签前弹出确认框',
'settings.render.confirmSshTabClose': '关闭 SSH 连接标签二次确认',
'settings.render.confirmSshTabCloseDesc': '关闭 SSH 连接标签前弹出确认框',
'settings.render.suggest': '输入建议', 'settings.render.suggest': '输入建议',
'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行', 'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行',
'settings.render.history': '记录命令历史', 'settings.render.history': '记录命令历史',
@@ -105,6 +109,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 集成', 'settings.system.shellIntegration': 'Shell 集成',
'settings.system.shellIntegrationDesc': 'settings.system.shellIntegrationDesc':
'让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可', '让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可',
'settings.system.diagnosticLog': '记录诊断日志',
'settings.system.diagnosticLogDesc':
'把启动、界面卡死、崩溃等关键事件写入日志目录(按天一个文件,保留 7 天自动清理),排查问题时用;关闭后不再记录',
'settings.system.closeAction': '关闭按钮行为', 'settings.system.closeAction': '关闭按钮行为',
'settings.system.closeActionDesc': 'settings.system.closeActionDesc':
'点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出', '点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'workspace.tab.localTerminal': '本地终端', 'workspace.tab.localTerminal': '本地终端',
'workspace.tab.closeOthers': '关闭其他', 'workspace.tab.closeOthers': '关闭其他',
'workspace.tab.closeRight': '关闭右侧', 'workspace.tab.closeRight': '关闭右侧',
'workspace.tab.closeConfirmTitle': '确认关闭标签',
'workspace.tab.closeConfirmOne': '确定要关闭「{title}」吗?对应的会话将被终止。',
'workspace.tab.closeConfirmMany': '确定要关闭 {count} 个标签吗?对应的会话将被终止。',
'workspace.tab.closeConfirmOk': '关闭',
'workspace.tab.broadcastTarget': '广播目标', 'workspace.tab.broadcastTarget': '广播目标',
'workspace.tab.broadcastBadge': '播', 'workspace.tab.broadcastBadge': '播',
'workspace.tab.addInSplit': '在此分屏中新建终端', 'workspace.tab.addInSplit': '在此分屏中新建终端',
+7
View File
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製', 'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製',
'settings.render.pasteRiskConfirm': '貼上風險確認', 'settings.render.pasteRiskConfirm': '貼上風險確認',
'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上', 'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上',
'settings.render.confirmTabClose': '關閉終端標籤二次確認',
'settings.render.confirmTabCloseDesc': '關閉本地終端標籤前彈出確認框',
'settings.render.confirmSshTabClose': '關閉 SSH 連線標籤二次確認',
'settings.render.confirmSshTabCloseDesc': '關閉 SSH 連線標籤前彈出確認框',
'settings.render.suggest': '輸入建議', 'settings.render.suggest': '輸入建議',
'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行', 'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行',
'settings.render.history': '記錄指令歷史', 'settings.render.history': '記錄指令歷史',
@@ -105,6 +109,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 整合', 'settings.system.shellIntegration': 'Shell 整合',
'settings.system.shellIntegrationDesc': 'settings.system.shellIntegrationDesc':
'讓 shell 主動回報工作目錄(OSC 7),路徑記憶更精確(含指令碼內的 cd);會包裝你的命令提示字元,提示字元主題顯示異常時關閉即可', '讓 shell 主動回報工作目錄(OSC 7),路徑記憶更精確(含指令碼內的 cd);會包裝你的命令提示字元,提示字元主題顯示異常時關閉即可',
'settings.system.diagnosticLog': '記錄診斷日誌',
'settings.system.diagnosticLogDesc':
'將啟動、介面凍結、當機等關鍵事件寫入日誌目錄(每日一個檔案,保留 7 天自動清除),排查問題時使用;關閉後不再記錄',
'settings.system.closeAction': '關閉按鈕行為', 'settings.system.closeAction': '關閉按鈕行為',
'settings.system.closeActionDesc': 'settings.system.closeActionDesc':
'點擊視窗關閉按鈕時的動作;最小化至系統匣後,可從系統匣圖示的右鍵選單結束', '點擊視窗關閉按鈕時的動作;最小化至系統匣後,可從系統匣圖示的右鍵選單結束',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'workspace.tab.localTerminal': '本機終端機', 'workspace.tab.localTerminal': '本機終端機',
'workspace.tab.closeOthers': '關閉其他', 'workspace.tab.closeOthers': '關閉其他',
'workspace.tab.closeRight': '關閉右側', 'workspace.tab.closeRight': '關閉右側',
'workspace.tab.closeConfirmTitle': '確認關閉標籤',
'workspace.tab.closeConfirmOne': '確定要關閉「{title}」嗎?對應的會話將被終止。',
'workspace.tab.closeConfirmMany': '確定要關閉 {count} 個標籤嗎?對應的會話將被終止。',
'workspace.tab.closeConfirmOk': '關閉',
'workspace.tab.broadcastTarget': '廣播目標', 'workspace.tab.broadcastTarget': '廣播目標',
'workspace.tab.broadcastBadge': '播', 'workspace.tab.broadcastBadge': '播',
'workspace.tab.addInSplit': '在此分割中新增終端機', 'workspace.tab.addInSplit': '在此分割中新增終端機',
+2
View File
@@ -98,6 +98,8 @@ export const Ipc = {
// ---- misc ---- // ---- misc ----
APP_INFO: 'app:info', APP_INFO: 'app:info',
/** renderer -> main: append one line to the diagnostic app log (fire-and-forget) */
APP_LOG: 'app:log',
// ---- updater (check/download/install + state broadcast) ---- // ---- updater (check/download/install + state broadcast) ----
UPDATE_CHECK: 'update:check', UPDATE_CHECK: 'update:check',
+14 -1
View File
@@ -66,6 +66,10 @@ export interface TerminalSettings {
copyOnSelect: boolean copyOnSelect: boolean
/** confirm before pasting multi-line / suspicious text */ /** confirm before pasting multi-line / suspicious text */
pasteRiskConfirm: boolean pasteRiskConfirm: boolean
/** Ask for confirmation before a user-initiated close of a local terminal tab. */
confirmTabClose: boolean
/** Same for SSH connection tabs. */
confirmSshTabClose: boolean
/** inline command suggestion popup while typing (history + library) */ /** inline command suggestion popup while typing (history + library) */
suggestEnabled: boolean suggestEnabled: boolean
/** record executed commands into the history panel */ /** record executed commands into the history panel */
@@ -207,6 +211,12 @@ export interface SystemSettings {
shellIntegration?: boolean shellIntegration?: boolean
/** interface language (see @shared/i18n); missing = 简体中文 */ /** interface language (see @shared/i18n); missing = 简体中文 */
language?: Language language?: Language
/**
* Persist the diagnostic log (startup, freeze watchdogs, crashes) to
* <userData>/logs with a 7-day rolling cleanup. On by default — the log is
* what makes "the app froze" field reports diagnosable. Off = record nothing.
*/
diagnosticLog?: boolean
} }
/** /**
@@ -470,6 +480,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
rendererMode: 'auto', rendererMode: 'auto',
copyOnSelect: false, copyOnSelect: false,
pasteRiskConfirm: true, pasteRiskConfirm: true,
confirmTabClose: false,
confirmSshTabClose: false,
suggestEnabled: false, suggestEnabled: false,
historyEnabled: false, historyEnabled: false,
historyLimit: 100, historyLimit: 100,
@@ -501,7 +513,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
autoDownloadUpdate: false, autoDownloadUpdate: false,
restoreSession: true, restoreSession: true,
shellIntegration: false, shellIntegration: false,
language: 'zh-CN' language: 'zh-CN',
diagnosticLog: true
}, },
// Off until the user sets a password and turns it on: an app that locks // Off until the user sets a password and turns it on: an app that locks
// itself out of the box would be a support ticket, not a feature. // itself out of the box would be a support ticket, not a feature.
+152
View File
@@ -0,0 +1,152 @@
/**
* Diagnostic app log self-test (app-log.mjs).
*
* `src/main/appLog.ts` persists the freeze watchdogs (`[main]` / `[renderer]
* stalled`) that a packaged build's console can never show. What is pinned:
*
* - daily naming is local-date (`app-YYYY-MM-DD.log`) and round-trips
* through the parser; foreign names parse to null;
* - retention keeps today plus the previous N days: a file exactly N days
* old stays, N+1 days is removed, foreign files in the directory are
* never touched;
* - `sweepAppLogs` on a real temp dir removes exactly the expired set and
* reports it;
* - `appLog` appends one `[stamp] [source] line` per call, serializes
* concurrent appends (order preserved), flattens embedded newlines, and
* truncates runaway payloads;
* - before `initAppLog` a line is dropped, not written anywhere.
*
* Build: node tests/build-bundles.cjs
* Run: node tests/app-log.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const { appLog, appLogName, initAppLog, isExpiredAppLog, parseAppLogName, setAppLogEnabled, sweepAppLogs, APP_LOG_RETENTION_DAYS } =
require('./.app-log.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const DAY = 24 * 60 * 60 * 1000
// A fixed LOCAL timestamp: 2026-10-11 12:00 local time.
const now = new Date(2026, 9, 11, 12, 0, 0)
const daysAgo = (n) => appLogName(new Date(now.getTime() - n * DAY))
console.log('daily naming is local-date and round-trips')
ok(appLogName(now) === 'app-2026-10-11.log', `name is app-2026-10-11.log (got ${appLogName(now)})`)
ok(parseAppLogName('app-2026-10-11.log') === new Date(2026, 9, 11).getTime(), 'name parses to local midnight')
ok(parseAppLogName(appLogName(now)) === new Date(2026, 9, 11).getTime(), 'name round-trips')
ok(appLogName(new Date(2026, 0, 5, 23, 59)) === 'app-2026-01-05.log', 'month/day are zero-padded')
console.log('foreign names never parse and never expire')
for (const name of ['app.log', 'app-2026-1-1.log', 'notes.txt', 'app-2026-13-01.log.log', 'app-2026-10-11.log.bak']) {
ok(parseAppLogName(name) === null, `${name} parses to null`)
ok(!isExpiredAppLog(name, now), `${name} never expires`)
}
console.log('retention keeps today plus the previous N days')
ok(!isExpiredAppLog(daysAgo(0), now), 'today is kept')
ok(!isExpiredAppLog(daysAgo(APP_LOG_RETENTION_DAYS), now), `exactly ${APP_LOG_RETENTION_DAYS} days old is kept`)
ok(isExpiredAppLog(daysAgo(APP_LOG_RETENTION_DAYS + 1), now), `${APP_LOG_RETENTION_DAYS + 1} days old expires`)
ok(isExpiredAppLog(daysAgo(365), now), 'a year-old file expires')
console.log('sweep removes exactly the expired set on a real directory')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
for (const n of [daysAgo(0), daysAgo(3), daysAgo(APP_LOG_RETENTION_DAYS), daysAgo(APP_LOG_RETENTION_DAYS + 1), daysAgo(60)]) {
writeFileSync(join(dir, n), 'x')
}
writeFileSync(join(dir, 'keep-me.txt'), 'x')
const removed = await sweepAppLogs(dir, now)
const left = readdirSync(dir).sort()
ok(removed.length === 2, `two files removed (got ${removed.length})`)
ok(removed.includes(daysAgo(APP_LOG_RETENTION_DAYS + 1)) && removed.includes(daysAgo(60)), 'the expired set is exactly right')
ok(left.length === 4 && left.includes('keep-me.txt') && left.includes(daysAgo(APP_LOG_RETENTION_DAYS)), 'today, boundary and foreign files survive')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('appLog appends serialized, single-line entries')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await Promise.all([appLog('main', 'first'), appLog('renderer', 'second\nwith newline'), appLog('main', 'third')])
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
const lines = content.trimEnd().split('\n')
ok(lines.length === 3, `three appends -> three lines (got ${lines.length})`)
ok(/\[main\s*\] first$/.test(lines[0]), 'order is preserved (1)')
ok(/\[renderer\] second with newline$/.test(lines[1]), 'embedded newlines are flattened (2)')
ok(/\[main\s*\] third$/.test(lines[2]), 'order is preserved (3)')
ok(lines.every((l) => /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[(?:main|renderer)\s*\] /.test(l)), 'every line carries a local timestamp and source')
ok(lines[0].includes('[main ]') && lines[1].includes('[renderer]'), 'the source column is padded to a fixed width for readability')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('the settings gate drops lines while off and resumes when on')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await appLog('main', 'before off')
setAppLogEnabled(false)
await appLog('main', 'while off')
setAppLogEnabled(true)
await appLog('main', 'back on')
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
ok(content.includes('before off'), 'on: line recorded')
ok(!content.includes('while off'), 'off: line dropped, nothing recorded')
ok(content.includes('back on'), 're-enabled: recording resumes')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('runaway payloads are truncated to one bounded line')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await appLog('main', 'x'.repeat(10000))
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
ok(content.length < 3000, `the line is bounded (got ${content.length} bytes)`)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('init sweeps expired files on the way up')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
// Relative to the REAL today: init sweeps with the wall clock.
writeFileSync(join(dir, 'app-2020-01-01.log'), 'x')
writeFileSync(join(dir, appLogName(new Date())), 'x')
initAppLog(dir)
await appLog('main', 'after init') // drains behind the init sweep in the same queue
const left = readdirSync(dir)
ok(!left.includes('app-2020-01-01.log'), 'expired file is gone after init')
ok(left.includes(appLogName(new Date())), "today's file survives init")
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
if (failed > 0) {
console.error(`\n[app-log] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[app-log] ALL CHECKS PASSED')
+3 -1
View File
@@ -71,7 +71,9 @@ const BUNDLES = [
// The smoke test imports the renderer engine (.ts), so it needs bundling too. // 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' }, { entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' },
// Preset-rule assertions (word boundaries, case flag) over the same engine. // Preset-rule assertions (word boundaries, case flag) over the same engine.
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' } { entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' },
// Diagnostic app log: daily naming, retention sweep, async append queue.
{ entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' }
] ]
for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) { for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) {
+49
View File
@@ -0,0 +1,49 @@
/**
* Refit-throttle self-test (refit-throttle.mjs).
*
* `src/renderer/src/terminal/fitThrottle.ts` decides whether a resize event
* may reflow now or must wait. The contract it pins:
*
* - `lastFitAt <= 0` (no fit recorded, e.g. a freshly bound session) always
* fits immediately — the pty spawns at a placeholder size and the shell
* may already be drawing, so the first fit must never wait;
* - inside the throttle window the answer is the REMAINING wait (feeds the
* trailing timer that lands the final fit at the settled size);
* - at and past the window boundary the fit runs now;
* - the window length is configurable so the test does not depend on the
* production constant's value.
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and
* no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/refit-throttle.mjs (must exit 0)
*/
import { FIT_REFLOW_THROTTLE_MS, refitDelay } from '../src/renderer/src/terminal/fitThrottle.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
console.log('the first fit never waits')
ok(refitDelay(0, 1000) === 0, 'lastFitAt = 0 fits immediately')
ok(refitDelay(-1, 1000) === 0, 'a negative marker also fits immediately')
console.log('inside the window the answer is the remaining wait')
ok(refitDelay(1000, 1000) === FIT_REFLOW_THROTTLE_MS, 'a fit at the same tick waits the full window')
ok(refitDelay(1000, 1100) === FIT_REFLOW_THROTTLE_MS - 100, 'mid-window returns the remainder')
ok(refitDelay(1000, 1000 + FIT_REFLOW_THROTTLE_MS - 1) === 1, 'one ms before the boundary waits 1ms')
console.log('at and past the boundary the fit runs now')
ok(refitDelay(1000, 1000 + FIT_REFLOW_THROTTLE_MS) === 0, 'exactly at the boundary fits')
ok(refitDelay(1000, 100000) === 0, 'long after the last fit fits')
console.log('the window length is a parameter')
ok(refitDelay(1000, 1050, 150) === 100, 'explicit throttleMs is honored')
ok(refitDelay(1000, 1150, 150) === 0, 'explicit window boundary fits')
if (failed > 0) {
console.error(`\n[refit-throttle] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[refit-throttle] ALL CHECKS PASSED')
+11
View File
@@ -185,6 +185,17 @@ console.log('[value bands]')
ok(back.terminal.highlightStats === on, `the stats switch '${on}' survives the round trip`) ok(back.terminal.highlightStats === on, `the stats switch '${on}' survives the round trip`)
} }
} }
{
for (const on of [true, false]) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, confirmTabClose: on, confirmSshTabClose: on }
})
ok(
back.terminal.confirmTabClose === on && back.terminal.confirmSshTabClose === on,
`tab close confirm switches '${on}' survive the round trip`
)
}
}
{ {
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })] const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
await roundTrip({ highlightRules: rules }) await roundTrip({ highlightRules: rules })
+105
View File
@@ -0,0 +1,105 @@
/**
* WriteBatcher self-test (write-batcher.mjs).
*
* `src/renderer/src/terminal/writeBatcher.ts` sits between the highlight
* stream and `term.write`: it exists because a busy TUI delivers one IPC
* message per pty read, and one `term.write` per message re-arms xterm's
* parser scheduler hundreds of times a second — the parser falls permanently
* behind and the UI freezes while the backlog drains. The contract the table
* below pins:
*
* - pushes inside one window land in a SINGLE sink call, concatenated in
* arrival order; the first push arms the only timer;
* - after a flush the batcher is reusable: the next push arms a new timer;
* - `flushNow` emits immediately and cancels the pending timer (the session
* -death path relies on this — PTY_EXIT lands before the timer would);
* - `dispose` drops buffered text and cancels the timer (teardown/rebind);
* - flushing an empty batcher never calls the sink.
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and
* no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/write-batcher.mjs (must exit 0)
*/
import { WriteBatcher } from '../src/renderer/src/terminal/writeBatcher.ts'
const DELAY = 10
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const makeSink = () => {
const calls = []
return { calls, sink: (text) => calls.push(text) }
}
console.log('pushes inside one window land in a single ordered flush')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('a')
b.push('b')
b.push('c')
ok(calls.length === 0, 'nothing is written before the window elapses')
ok(b.hasPending(), 'hasPending reports buffered text')
await sleep(DELAY * 4)
ok(calls.length === 1, `one sink call for three pushes (got ${calls.length})`)
ok(calls[0] === 'abc', 'order is preserved across pushes')
ok(!b.hasPending(), 'buffer is empty after the flush')
b.dispose()
}
console.log('the batcher is reusable after a flush')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('first')
await sleep(DELAY * 4)
b.push('second')
await sleep(DELAY * 4)
ok(calls.length === 2 && calls[0] === 'first' && calls[1] === 'second', 'each window flushes once')
b.dispose()
}
console.log('flushNow emits immediately and cancels the pending timer')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('tail')
b.flushNow()
ok(calls.length === 1 && calls[0] === 'tail', 'flushNow writes synchronously')
await sleep(DELAY * 4)
ok(calls.length === 1, 'the cancelled timer never fires a second write')
b.dispose()
}
console.log('dispose drops buffered text and the timer')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('dropped')
b.dispose()
ok(!b.hasPending(), 'dispose clears the buffer')
await sleep(DELAY * 4)
ok(calls.length === 0, 'disposed batcher never writes')
}
console.log('an empty flush never reaches the sink')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.flushNow()
b.push('')
await sleep(DELAY * 4)
ok(calls.length === 0, 'no sink call without content')
b.dispose()
}
if (failed > 0) {
console.error(`\n[write-batcher] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[write-batcher] ALL CHECKS PASSED')