Compare commits

...
4 Commits
Author SHA1 Message Date
Bill 2b658520a4 fix(updater): 禁用注定失败的差量下载,修复更新下载两次;官网同步 v1.0.28(补 1.0.26/1.0.27)
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-16 00:53:00 +08:00
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
40 changed files with 1105 additions and 111 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
+13 -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` 里按源切换
@@ -51,6 +59,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查 - **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查
- **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期 - **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期
- **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」 - **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」
- **差量下载已禁用**(`autoUpdater.disableDifferentialDownload = true`,configureAutoUpdater 里):electron-updater 每次 `downloadUpdate()` 先试差量(拉新旧 blockmap + 多段 Range)失败再回退全量——用户看到进度条跑两遍、最坏近乎双倍流量(v1.0.x 用户报的「下载两次」)。而本项目三个前提让它注定失败:GitHub release 资产落 Azure Blob,多段 Range 返回 `501 Unsupported client range`(已实测);国内 Gitea 通道被 `pruneChannel` 删得只剩最新版 exe/blockmap,老客户端取老 blockmap 得 404;NSIS 是 LZMA 固实压缩,版本间小改动让后续压缩数据整体漂移,差量区间≈整个文件。所以直接全量是唯一正确行为
- **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled` - **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`
- `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」 - `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」
- **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹 - **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹
@@ -68,7 +77,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 +117,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`**,会话已死自动关闭/模板应用等程序性路径不走
## 关键词高亮 ## 关键词高亮
+38
View File
@@ -1,5 +1,43 @@
# OpenTerminal Changelog # OpenTerminal Changelog
## v1.0.28 - 2026-10-15
This release fixes the "downloads twice" issue during in-app updates: when updating from an older version the progress bar ran through twice, costing nearly double the traffic in the worst case.
### Fixed: update downloads twice
- The root cause: the updater tried a "differential download" (fetching only the changed chunks) on every download and fell back to a full re-download when it failed — and neither of our update sources can serve differential downloads, so every update ran as a differential attempt followed by a full package
- Differential download is now disabled: **every update is exactly one full download**, and the progress bar runs through once
- Note: users still on an older version will see the double download one last time while updating TO this version (the fix has to reach them first); once this version is installed, every future update downloads once
## 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
+38
View File
@@ -1,5 +1,43 @@
# OpenTerminal 更新履歴 # OpenTerminal 更新履歴
## v1.0.28 - 2026-10-15
本リリースではアプリ内アップデートの「2 回ダウンロード」問題を修正しました。旧バージョンからの更新時にプログレスバーが 2 周し、最悪の場合ほぼ倍の通信量が発生していました。
### 修正:アップデートが 2 回ダウンロードされる
- 根本原因は、アップデーターが毎回「差分ダウンロード」(変更されたチャンクのみ取得)を試み、失敗するとパッケージ全体を再ダウンロードすることでした——本プロジェクトの 2 つの更新ソースはいずれも差分ダウンロードの条件を満たさないため、毎回「差分試行 + 全パッケージ」の 2 段階になっていました
- 差分ダウンロードを無効化し、**毎回ちょうど 1 回のフルダウンロード**になり、プログレスバーは 1 周だけです
- 注意:旧バージョンのユーザーが本バージョンへ更新する際は、最後にもう一度だけ 2 回ダウンロードが発生します(修正コードが先に手元へ届く必要があるため)。本バージョン導入後の更新はすべて 1 回のダウンロードになります
## 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
+38
View File
@@ -1,5 +1,43 @@
# OpenTerminal 更新日志 # OpenTerminal 更新日志
## v1.0.28 - 2026-10-15
本次修复应用内更新「下载两次」的问题:从旧版本更新时进度条会跑两遍,最坏情况消耗近双倍流量。
### 修复:更新下载两次
- 根因是更新组件每次下载都先尝试「差量下载」(只取变化的数据块),失败后回退整包重下——而本项目的两个更新源都不具备差量条件,导致每次更新都是"差量尝试 + 整包"两段式
- 现已禁用差量下载,**每次更新恰好一次整包下载**,进度条只走一遍
- 注意:还在旧版本上的用户,更新到本版本时会经历**最后一次**下载两次(修复代码要先到达用户手里);装上本版本后,之后的每次更新都是单次下载
## 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
新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。 新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。
+38
View File
@@ -1,5 +1,43 @@
# OpenTerminal 更新日誌 # OpenTerminal 更新日誌
## v1.0.28 - 2026-10-15
本次修復應用內更新「下載兩次」的問題:從舊版本更新時進度條會跑兩遍,最壞情況消耗近雙倍流量。
### 修復:更新下載兩次
- 根因是更新元件每次下載都先嘗試「差量下載」(只取變化的資料塊),失敗後回退整包重下——而本專案的兩個更新來源都不具備差量條件,導致每次更新都是「差量嘗試 + 整包」兩段式
- 現已停用差量下載,**每次更新恰好一次整包下載**,進度條只走一遍
- 注意:還在舊版本上的使用者,更新到此版本時會經歷**最後一次**下載兩次(修復程式要先到達使用者手裡);裝上此版本後,之後的每次更新都是單次下載
## 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.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.25", "version": "1.0.28",
"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.28",
"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)
+15
View File
@@ -499,6 +499,21 @@ export function configureAutoUpdater(): void {
// ever installs on its own — autoInstallOnAppQuit only covers quitting. // ever installs on its own — autoInstallOnAppQuit only covers quitting.
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoInstallOnAppQuit = true
// Differential download is off because it cannot succeed against either of
// our feeds, and every failed attempt costs the user a second full transfer:
// electron-updater's differential path fetches the old and new blockmaps and
// then multi-range-requests the changed chunks, falling back to a plain whole-
// file download when any of that fails. GitHub serves the release assets from
// Azure Blob, which answers multi-range requests with `501 Unsupported client
// range` (measured), and the Gitea channel keeps only the newest exe + blockmap
// (`pruneChannel` deletes the previous release's), so an older client's
// blockmap 404s. Even where both were available the NSIS payload is LZMA solid
// compression: a small version-to-version change shifts everything after it,
// making the differential range nearly the whole file. The user then watches
// the progress bar run twice — worst case for roughly double the traffic — so
// going straight to the full download is the only behavior that is actually
// correct here.
autoUpdater.disableDifferentialDownload = true
// The startup check and the periodic one are armed by applyUpdateSchedule // The startup check and the periodic one are armed by applyUpdateSchedule
// (wired in index.ts through settingsStore's injected applier) instead of // (wired in index.ts through settingsStore's injected applier) instead of
+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 = ''
}
}
+120 -17
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,26 +148,32 @@ 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()
} else if (key === 'closeOthers') { return
// `panel.panels` is the live group array and `close()` splices it, so }
// `api.group.panels` is the live group array and `close()` splices it, so
// the targets must be snapshotted first — otherwise every element that // the targets must be snapshotted first — otherwise every element that
// shifts into the current index is skipped. // shifts into the current index is skipped. Read through `api.group`
for (const sibling of [...panel.panels]) { // rather than a render-time copy in case the tab has since been dragged
if (sibling.api !== api) closePanel(sibling) // into another group.
} const siblings = [...api.group.panels]
if (key === 'close') {
requestClose([self])
} else if (key === 'closeOthers') {
requestClose(siblings.filter((sibling) => sibling.api !== api))
} 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']}> <Dropdown menu={menu} trigger={['contextMenu']}>
<div <div
ref={rootRef}
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`} className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
title={tooltip} title={tooltip}
> >
@@ -110,13 +197,29 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
api.close() const self = selfPanel()
if (self) requestClose([self])
}} }}
> >
<CloseGlyph /> <CloseGlyph />
</button> </button>
</div> </div>
</Dropdown> </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 })
+7
View File
@@ -439,6 +439,13 @@ console.log('updater events drive the state the renderer reads')
ok(live.logger === console, 'the updater logs through console') ok(live.logger === console, 'the updater logs through console')
ok(live.autoDownload === false, 'auto-download is left to the user (the UI offers the button)') ok(live.autoDownload === false, 'auto-download is left to the user (the UI offers the button)')
ok(live.autoInstallOnAppQuit === true, 'a downloaded update installs on app quit') ok(live.autoInstallOnAppQuit === true, 'a downloaded update installs on app quit')
// Differential download is disabled: it cannot work against either feed (GitHub
// assets reject multi-range requests, the Gitea channel keeps no old blockmap),
// and each failure costs a second full transfer.
ok(
live.disableDifferentialDownload === true,
'differential download is off (a failed attempt would download the package twice)'
)
const before = await call(Ipc.UPDATE_STATE_GET) const before = await call(Ipc.UPDATE_STATE_GET)
ok(before !== undefined && typeof before.currentVersion === 'string', 'UPDATE_STATE_GET returns the current state') ok(before !== undefined && typeof before.currentVersion === 'string', 'UPDATE_STATE_GET returns the current state')
+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')
+40 -52
View File
@@ -155,6 +155,16 @@ window.OPENTERMINAL_I18N = {
'cl.eyebrow': 'Changelog', 'cl.eyebrow': 'Changelog',
'cl.title': '近期更新', 'cl.title': '近期更新',
'changelog.1.0.28.li.1': '修复应用内更新会下载两次的问题:禁用注定失败的差量下载,每次更新恰好一次整包下载,进度条只走一遍',
'changelog.1.0.27.li.1': '新增关闭标签二次确认:终端 / SSH 两个独立开关(默认关闭),设置 → 渲染与行为',
'changelog.1.0.27.li.2': '覆盖标签 × 按钮、右键「关闭 / 关闭其他 / 关闭右侧」与键盘 Delete / Backspace 误关——点击标签后按 Backspace 不再直接关闭',
'changelog.1.0.27.li.3': '批量关闭整批只确认一次;开关保存后立即生效,无需重启',
'changelog.1.0.26.li.1': '修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)',
'changelog.1.0.26.li.2': '新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭',
'changelog.1.0.26.li.3': '反馈卡顿问题时附上当天的日志文件即可精确定位',
'changelog.1.0.25.li.1': '定时检查更新(从不 / 4 / 12 / 24 小时,默认 4)', 'changelog.1.0.25.li.1': '定时检查更新(从不 / 4 / 12 / 24 小时,默认 4)',
'changelog.1.0.25.li.2': '自动下载更新开关(后台静默下载,安装时机仍由用户决定,绝不自动退出)', 'changelog.1.0.25.li.2': '自动下载更新开关(后台静默下载,安装时机仍由用户决定,绝不自动退出)',
'changelog.1.0.25.li.3': '下载完成 / 发现新版本时窗口最小化到托盘则弹提示', 'changelog.1.0.25.li.3': '下载完成 / 发现新版本时窗口最小化到托盘则弹提示',
@@ -165,19 +175,6 @@ window.OPENTERMINAL_I18N = {
'changelog.1.0.24.li.3': '会话日志退出不再丢尾部', 'changelog.1.0.24.li.3': '会话日志退出不再丢尾部',
'changelog.1.0.24.li.4': 'SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试', 'changelog.1.0.24.li.4': 'SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试',
'changelog.1.0.23.li.1': 'SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序',
'changelog.1.0.23.li.2': '字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置',
'changelog.1.0.23.li.3': '用户 / 组列显示真实名称,不再只有数字 UID / GID',
'changelog.1.0.23.li.4': '列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题',
'changelog.1.0.22.li.1': '修复中文输入法候选窗不跟随光标(v1.0.21 回归)',
'changelog.1.0.22.li.2': '不再提供 MSI 安装包,统一使用 NSIS exe',
'changelog.1.0.22.li.3': '打包流程新增依赖一致性校验,防止旧依赖混入安装包',
'changelog.1.0.21.li.1': '背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆',
'changelog.1.0.21.li.2': '性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界',
'changelog.1.0.21.li.3': '安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固',
'cl.morePre': '完整日志见仓库', 'cl.morePre': '完整日志见仓库',
'cl.morePost': '。', 'cl.morePost': '。',
@@ -332,6 +329,16 @@ window.OPENTERMINAL_I18N = {
'cl.eyebrow': 'Changelog', 'cl.eyebrow': 'Changelog',
'cl.title': '近期更新', 'cl.title': '近期更新',
'changelog.1.0.28.li.1': '修復應用內更新會下載兩次的問題:停用注定失敗的差量下載,每次更新恰好一次整包下載,進度條只走一遍',
'changelog.1.0.27.li.1': '新增關閉分頁二次確認:終端機 / SSH 兩個獨立開關(預設關閉),設定 → 渲染與行為',
'changelog.1.0.27.li.2': '涵蓋分頁 × 按鈕、右鍵「關閉 / 關閉其他 / 關閉右側」與鍵盤 Delete / Backspace 誤關——點擊分頁後按 Backspace 不再直接關閉',
'changelog.1.0.27.li.3': '批量關閉整批只確認一次;開關儲存後立即生效,無需重啟',
'changelog.1.0.26.li.1': '修復拖動縮放視窗或終端機高頻輸出時介面凍結數秒(網格重排節流 + 輸出批量寫入)',
'changelog.1.0.26.li.2': '新增落盤診斷日誌:預設開啟,按天一個檔案保留 7 天,設定 → 系統可關閉',
'changelog.1.0.26.li.3': '回報卡頓問題時附上當天的日誌檔案即可精確定位',
'changelog.1.0.25.li.1': '定時檢查更新(從不 / 4 / 12 / 24 小時,預設 4)', 'changelog.1.0.25.li.1': '定時檢查更新(從不 / 4 / 12 / 24 小時,預設 4)',
'changelog.1.0.25.li.2': '自動下載更新開關(背景靜默下載,安裝時機仍由使用者決定,絕不自動結束)', 'changelog.1.0.25.li.2': '自動下載更新開關(背景靜默下載,安裝時機仍由使用者決定,絕不自動結束)',
'changelog.1.0.25.li.3': '下載完成 / 發現新版本時,視窗若最小化到系統匣會彈出提示', 'changelog.1.0.25.li.3': '下載完成 / 發現新版本時,視窗若最小化到系統匣會彈出提示',
@@ -342,19 +349,6 @@ window.OPENTERMINAL_I18N = {
'changelog.1.0.24.li.3': '工作階段日誌結束時不再遺失尾段', 'changelog.1.0.24.li.3': '工作階段日誌結束時不再遺失尾段',
'changelog.1.0.24.li.4': 'SFTP 刪除不再誤報失敗;ZMODEM 接收大檔案的記憶體用量可控;新增 3 組渲染層測試', 'changelog.1.0.24.li.4': 'SFTP 刪除不再誤報失敗;ZMODEM 接收大檔案的記憶體用量可控;新增 3 組渲染層測試',
'changelog.1.0.23.li.1': 'SFTP 檔案面板改為真正的表格:六欄對齊、表頭固定、點擊表頭排序',
'changelog.1.0.23.li.2': '字型可縮放 0.8–1.8 倍(A- / A+ 或 Ctrl+滾輪),行高連動並記憶設定',
'changelog.1.0.23.li.3': '使用者 / 群組欄顯示真實名稱,不再只有數字 UID / GID',
'changelog.1.0.23.li.4': '清單空白處按右鍵即可喚出檔案選單;修復六欄先前未真正依格線對齊的問題',
'changelog.1.0.22.li.1': '修復中文輸入法候選窗不跟隨游標(v1.0.21 回歸)',
'changelog.1.0.22.li.2': '不再提供 MSI 安裝程式,統一使用 NSIS exe',
'changelog.1.0.22.li.3': '打包流程新增相依性一致性校驗,防止舊相依性混入安裝程式',
'changelog.1.0.21.li.1': '背景圖片體驗:任何主題下調低不透明度都是壓暗、文字自動提亮至 WCAG AA、新增背景壓暗滑桿',
'changelog.1.0.21.li.2': '效能:設定熱更新不再觸發全部窗格重排,SFTP 大目錄虛擬捲動,面板獨立錯誤邊界',
'changelog.1.0.21.li.3': '安裝程式精簡約 90MB;SFTP/ZMODEM 本機路徑准入與多處安全加固',
'cl.morePre': '完整日誌請見倉庫', 'cl.morePre': '完整日誌請見倉庫',
'cl.morePost': '。', 'cl.morePost': '。',
@@ -509,6 +503,16 @@ window.OPENTERMINAL_I18N = {
'cl.eyebrow': 'Changelog', 'cl.eyebrow': 'Changelog',
'cl.title': 'Recent updates', 'cl.title': 'Recent updates',
'changelog.1.0.28.li.1': 'Fixed in-app updates downloading twice: differential download (which could never succeed) is disabled, so every update is exactly one full download with a single progress pass',
'changelog.1.0.27.li.1': 'Added close-confirmation for tabs: separate switches for terminal and SSH tabs (off by default), under Settings → Rendering & Behavior',
'changelog.1.0.27.li.2': 'Covers the tab × button, the context menu\'s Close / Close others / Close right, and accidental Delete / Backspace — pressing Backspace after clicking a tab no longer closes it',
'changelog.1.0.27.li.3': 'Batch closes ask once for the whole batch; switches take effect immediately, no restart needed',
'changelog.1.0.26.li.1': 'Fixed the UI freezing for seconds while resizing the window or under high-frequency terminal output (reflow throttling + batched writes)',
'changelog.1.0.26.li.2': 'Added on-disk diagnostic logs: on by default, one file per day kept for 7 days, can be disabled in Settings → System',
'changelog.1.0.26.li.3': 'Attach the day\'s log file when reporting freezes for precise diagnosis',
'changelog.1.0.25.li.1': 'Scheduled update checks (never / 4 / 12 / 24 hours, default 4)', 'changelog.1.0.25.li.1': 'Scheduled update checks (never / 4 / 12 / 24 hours, default 4)',
'changelog.1.0.25.li.2': 'Auto-download toggle: silent background download, install timing still up to you, never quits on its own', 'changelog.1.0.25.li.2': 'Auto-download toggle: silent background download, install timing still up to you, never quits on its own',
'changelog.1.0.25.li.3': 'Tray notification when a new version is found or a download finishes while the window is minimized', 'changelog.1.0.25.li.3': 'Tray notification when a new version is found or a download finishes while the window is minimized',
@@ -519,19 +523,6 @@ window.OPENTERMINAL_I18N = {
'changelog.1.0.24.li.3': 'Session logs no longer lose their tail when the app exits', 'changelog.1.0.24.li.3': 'Session logs no longer lose their tail when the app exits',
'changelog.1.0.24.li.4': 'SFTP delete no longer reports false failures; ZMODEM downloads of large files keep memory bounded; 3 new renderer test suites', 'changelog.1.0.24.li.4': 'SFTP delete no longer reports false failures; ZMODEM downloads of large files keep memory bounded; 3 new renderer test suites',
'changelog.1.0.23.li.1': 'SFTP file panel is now a real table: six aligned columns, sticky header, click-to-sort',
'changelog.1.0.23.li.2': 'Font scaling from 0.8x to 1.8x (A- / A+ or Ctrl+wheel); row height follows and the setting is remembered',
'changelog.1.0.23.li.3': 'User / group columns show real names instead of bare UID / GID numbers',
'changelog.1.0.23.li.4': 'Right-click empty list space for the file menu; fixed columns that were not actually aligned to the grid',
'changelog.1.0.22.li.1': 'Fixed the IME candidate window not following the cursor (regression in v1.0.21)',
'changelog.1.0.22.li.2': 'MSI installer dropped; NSIS exe is now the only package',
'changelog.1.0.22.li.3': 'Build pipeline now verifies dependency consistency to keep stale dependencies out of the installer',
'changelog.1.0.21.li.1': 'Background images: lowering opacity now always dims the image in any theme, text is auto-brightened to WCAG AA, and a new dim slider was added',
'changelog.1.0.21.li.2': 'Performance: settings hot-updates no longer reflow every pane, SFTP virtual scrolling for large directories, per-pane error boundaries',
'changelog.1.0.21.li.3': 'Installer slimmed by about 90MB; local path admission for SFTP / ZMODEM plus several security hardening fixes',
'cl.morePre': 'Full changelog in', 'cl.morePre': 'Full changelog in',
'cl.morePost': '.', 'cl.morePost': '.',
@@ -686,6 +677,16 @@ window.OPENTERMINAL_I18N = {
'cl.eyebrow': 'Changelog', 'cl.eyebrow': 'Changelog',
'cl.title': '最近の更新', 'cl.title': '最近の更新',
'changelog.1.0.28.li.1': 'アプリ内アップデートが 2 回ダウンロードされる問題を修正:成功しない差分ダウンロードを無効化し、毎回ちょうど 1 回のフルダウンロードに',
'changelog.1.0.27.li.1': 'タブを閉じる前の確認機能を追加:ターミナル / SSH それぞれ個別のスイッチ(既定はオフ)、設定 → 描画と動作',
'changelog.1.0.27.li.2': 'タブの × ボタン、右クリックメニューの「閉じる / ほかを閉じる / 右側を閉じる」、および Delete / Backspace による誤クローズに対応——タブクリック後の Backspace で閉じなくなります',
'changelog.1.0.27.li.3': 'バッチクローズは全体で 1 回だけ確認;スイッチは保存後すぐに反映され、再起動不要',
'changelog.1.0.26.li.1': 'ウィンドウのリサイズや高頻度ターミナル出力時に UI が数秒固まる問題を修正(リフローのスロットリング + 書き込みバッチ化)',
'changelog.1.0.26.li.2': '診断ログを追加:既定で有効、日ごとに 1 ファイルを 7 日間保持、設定 → システムで無効化可能',
'changelog.1.0.26.li.3': '固まりの報告時に当日のログファイルを添付すれば正確に特定できます',
'changelog.1.0.25.li.1': '定期的な更新チェック(しない / 4 / 12 / 24 時間、既定は 4 時間)', 'changelog.1.0.25.li.1': '定期的な更新チェック(しない / 4 / 12 / 24 時間、既定は 4 時間)',
'changelog.1.0.25.li.2': '自動ダウンロードのオン/オフ(バックグラウンドで静かにダウンロード。インストールのタイミングは従来どおりユーザーが決定し、自動で終了することはありません)', 'changelog.1.0.25.li.2': '自動ダウンロードのオン/オフ(バックグラウンドで静かにダウンロード。インストールのタイミングは従来どおりユーザーが決定し、自動で終了することはありません)',
'changelog.1.0.25.li.3': '新バージョンの検出時やダウンロード完了時に、ウィンドウがトレイに最小化されていれば通知を表示', 'changelog.1.0.25.li.3': '新バージョンの検出時やダウンロード完了時に、ウィンドウがトレイに最小化されていれば通知を表示',
@@ -696,19 +697,6 @@ window.OPENTERMINAL_I18N = {
'changelog.1.0.24.li.3': '終了時にセッションログの末尾が欠けなくなりました', 'changelog.1.0.24.li.3': '終了時にセッションログの末尾が欠けなくなりました',
'changelog.1.0.24.li.4': 'SFTP の削除が誤って失敗扱いにならなくなりました。ZMODEM の大容量受信もメモリ使用量を抑制。レンダラー層のテストを 3 組追加', 'changelog.1.0.24.li.4': 'SFTP の削除が誤って失敗扱いにならなくなりました。ZMODEM の大容量受信もメモリ使用量を抑制。レンダラー層のテストを 3 組追加',
'changelog.1.0.23.li.1': 'SFTP ファイルパネルを本格的なテーブルに変更:6 列の整列、ヘッダーの固定表示、ヘッダークリックで並べ替え',
'changelog.1.0.23.li.2': '文字サイズを 0.8〜1.8 倍に変更可能(A- / A+ または Ctrl+ホイール)。行の高さも連動し、設定は記憶されます',
'changelog.1.0.23.li.3': 'ユーザー / グループ列に実際の名前を表示。数字だけの UID / GID ではなくなりました',
'changelog.1.0.23.li.4': '一覧の空白部分を右クリックでファイルメニューを表示。6 列が実際にはグリッドに揃っていなかった問題を修正',
'changelog.1.0.22.li.1': 'IME の変換候補ウィンドウがカーソルに追従しない問題を修正(v1.0.21 のリグレッション)',
'changelog.1.0.22.li.2': 'MSI インストーラーの提供を終了し、NSIS exe に統一',
'changelog.1.0.22.li.3': 'ビルド手順に依存関係の整合性チェックを追加し、古い依存関係が混入しないようにしました',
'changelog.1.0.21.li.1': '背景画像の扱いを改善。どのテーマでも不透明度を下げると画像が暗くなり、文字は WCAG AA まで自動的に明るく調整。減光スライダーも追加',
'changelog.1.0.21.li.2': 'パフォーマンス:設定のホットアップデートで全ペインが再レイアウトされなくなり、SFTP の大規模ディレクトリは仮想スクロール、ペインごとにエラーバウンダリを追加',
'changelog.1.0.21.li.3': 'インストーラーを約 90MB 削減。SFTP / ZMODEM のローカルパス検証と複数のセキュリティ強化',
'cl.morePre': '完全な変更履歴は', 'cl.morePre': '完全な変更履歴は',
'cl.morePost': ' をご覧ください。', 'cl.morePost': ' をご覧ください。',
+27 -30
View File
@@ -118,14 +118,14 @@
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。 内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="hero.download"> <a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.28/OpenTerminal-1.0.28-setup.exe" data-i18n="hero.download">
下载 Windows 安装包 下载 Windows 安装包
</a> </a>
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener" data-i18n="hero.source"> <a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener" data-i18n="hero.source">
查看源码 查看源码
</a> </a>
</div> </div>
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.25</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p> <p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.28</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
</div> </div>
<figure class="hero-shot"> <figure class="hero-shot">
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" data-i18n-attr="alt:hero.shot.alt" /> <img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" data-i18n-attr="alt:hero.shot.alt" />
@@ -133,7 +133,7 @@
</figure> </figure>
</div> </div>
<div class="container spec-strip"> <div class="container spec-strip">
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.25</strong></span> <span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.28</strong></span>
<span data-i18n="spec.os">Windows 10 / 11 · x64</span> <span data-i18n="spec.os">Windows 10 / 11 · x64</span>
<span data-i18n="spec.license">MIT 开源协议</span> <span data-i18n="spec.license">MIT 开源协议</span>
<span data-i18n="spec.update">exe 支持应用内自动更新</span> <span data-i18n="spec.update">exe 支持应用内自动更新</span>
@@ -341,11 +341,11 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="mono">OpenTerminal-1.0.25-setup.exe</td> <td class="mono">OpenTerminal-1.0.28-setup.exe</td>
<td data-i18n="dl.table.exe">NSIS 安装包,支持应用内自动更新(推荐)</td> <td data-i18n="dl.table.exe">NSIS 安装包,支持应用内自动更新(推荐)</td>
<td class="mono">109 MB</td> <td class="mono">109 MB</td>
<td> <td>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="dl.table.download">下载</a> <a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.28/OpenTerminal-1.0.28-setup.exe" data-i18n="dl.table.download">下载</a>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -375,6 +375,28 @@
<p class="section-eyebrow" data-i18n="cl.eyebrow">Changelog</p> <p class="section-eyebrow" data-i18n="cl.eyebrow">Changelog</p>
<h2 data-i18n="cl.title">近期更新</h2> <h2 data-i18n="cl.title">近期更新</h2>
<div class="changelog-list"> <div class="changelog-list">
<article>
<h3><span class="mono">1.0.28</span><span class="rel-date">2026-10-16</span></h3>
<ul>
<li data-i18n="changelog.1.0.28.li.1">修复应用内更新会下载两次的问题:禁用注定失败的差量下载,每次更新恰好一次整包下载,进度条只走一遍</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.27</span><span class="rel-date">2026-10-15</span></h3>
<ul>
<li data-i18n="changelog.1.0.27.li.1">新增关闭标签二次确认:终端 / SSH 两个独立开关(默认关闭),设置 → 渲染与行为</li>
<li data-i18n="changelog.1.0.27.li.2">覆盖标签 × 按钮、右键「关闭 / 关闭其他 / 关闭右侧」与键盘 Delete / Backspace 误关——点击标签后按 Backspace 不再直接关闭</li>
<li data-i18n="changelog.1.0.27.li.3">批量关闭整批只确认一次;开关保存后立即生效,无需重启</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.26</span><span class="rel-date">2026-10-11</span></h3>
<ul>
<li data-i18n="changelog.1.0.26.li.1">修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)</li>
<li data-i18n="changelog.1.0.26.li.2">新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭</li>
<li data-i18n="changelog.1.0.26.li.3">反馈卡顿问题时附上当天的日志文件即可精确定位</li>
</ul>
</article>
<article> <article>
<h3><span class="mono">1.0.25</span><span class="rel-date">2026-10-10</span></h3> <h3><span class="mono">1.0.25</span><span class="rel-date">2026-10-10</span></h3>
<ul> <ul>
@@ -393,31 +415,6 @@
<li data-i18n="changelog.1.0.24.li.4">SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试</li> <li data-i18n="changelog.1.0.24.li.4">SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试</li>
</ul> </ul>
</article> </article>
<article>
<h3><span class="mono">1.0.23</span><span class="rel-date">2026-10-09</span></h3>
<ul>
<li data-i18n="changelog.1.0.23.li.1">SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
<li data-i18n="changelog.1.0.23.li.2">字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
<li data-i18n="changelog.1.0.23.li.3">用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
<li data-i18n="changelog.1.0.23.li.4">列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.22</span><span class="rel-date">2026-10-08</span></h3>
<ul>
<li data-i18n="changelog.1.0.22.li.1">修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
<li data-i18n="changelog.1.0.22.li.2">不再提供 MSI 安装包,统一使用 NSIS exe</li>
<li data-i18n="changelog.1.0.22.li.3">打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
</ul>
</article>
<article>
<h3><span class="mono">1.0.21</span><span class="rel-date">2026-10-07</span></h3>
<ul>
<li data-i18n="changelog.1.0.21.li.1">背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
<li data-i18n="changelog.1.0.21.li.2">性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
<li data-i18n="changelog.1.0.21.li.3">安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
</ul>
</article>
</div> </div>
<p class="changelog-more"> <p class="changelog-more">
<span data-i18n="cl.morePre">完整日志见仓库</span> <span data-i18n="cl.morePre">完整日志见仓库</span>