Compare commits
8
Commits
4e5377f49c
...
v1.0.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1376b7d1d | ||
|
|
d04452cd7f | ||
|
|
86f51df2e6 | ||
|
|
dd08f8054a | ||
|
|
dba2b14c33 | ||
|
|
680254cad6 | ||
|
|
b7c7c5cd76 | ||
|
|
462da60977 |
No files matched your search
@@ -27,6 +27,7 @@ tests/.commands-store.cjs
|
||||
tests/.known-hosts.cjs
|
||||
tests/.lock-store.cjs
|
||||
tests/.lock-controller.cjs
|
||||
tests/.lock-shortcuts.cjs
|
||||
tests/.settings-store.cjs
|
||||
tests/.session-e2e.cjs
|
||||
tests/.hl-split-smoke.cjs
|
||||
@@ -46,3 +47,6 @@ RELEASE_NOTES*.md
|
||||
|
||||
tmp-test/
|
||||
tmp-test*
|
||||
|
||||
# agent session scratch (SDD plans/reports/smoke artifacts)
|
||||
.superpowers/
|
||||
@@ -7,8 +7,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
|
||||
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
|
||||
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 10 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
|
||||
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 10 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次),产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 11 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
|
||||
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 11 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
|
||||
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
|
||||
@@ -28,18 +28,18 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore;可选 `.zh-TW/.en/.ja` 译文,缺译文回退简体)
|
||||
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
|
||||
3. `npm run dist` 构建
|
||||
4. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.14 --skip-github`
|
||||
- **只发国内**(2026-09-15 起的产品决定):Gitea release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发)
|
||||
- Gitea:release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
|
||||
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
|
||||
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
|
||||
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
|
||||
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
|
||||
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
|
||||
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)
|
||||
|
||||
## 更新机制
|
||||
|
||||
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
|
||||
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
|
||||
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
|
||||
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
|
||||
|
||||
@@ -62,10 +62,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却
|
||||
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
|
||||
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
|
||||
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
|
||||
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截
|
||||
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。**键盘判定抽在纯函数模块 `src/main/lockShortcuts.ts`(`isLockBlockedShortcut`/`isPanicLockChord`,无 Electron 依赖,表驱动测试 `tests/lock-shortcuts.mjs`)**——v1.0.17 的回归就是死在闭包里没法测。键盘之外还有鼠标路径:默认菜单按 Alt 就能唤出,菜单项点击不走 before-input-event,所以**锁定期间 `src/main/lockMenu.ts` 把整个应用菜单置 null(解锁时按 Electron 默认模板重建)**;菜单摘除挂在 `LockController` 的默认 publish 上,启动恢复锁定不经过 publish,由 `index.ts` 在 `initLockController()` 后按 `isLocked()` 直接补一次。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
|
||||
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截。长按的自动重复要跳过(`input.isAutoRepeat`),否则未配置密码时每次重复都同步读 lock.json + settings.json。**Ctrl+L 是保留键**:全局唤起快捷键的录制器(`SettingsTabs.tsx` 的 `RESERVED_EXACT_ACCELERATORS`)拒绝它——globalShortcut 在 OS 层拦截,绑上去会让锁屏快捷键静默失效。窗口藏进托盘后 Ctrl+L 无效(before-input-event 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键
|
||||
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
|
||||
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
|
||||
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例)
|
||||
|
||||
## 关键词高亮
|
||||
|
||||
@@ -83,7 +83,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 跟随主题(`settings.terminal.highlightThemeColors`,默认关):`src/renderer/src/theme/highlightColors.ts` 按**色相分桶**把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;**背景色不映射**(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致
|
||||
- 统计(`settings.terminal.highlightStats`,默认关):引擎的 `applyHighlights`/`HighlightStream` 接受可选 `StatsSink`,**只在传了 sink 时才计数与计时**(默认路径不插桩);`TerminalView` 只在开关打开时挂 sink,并**每秒发布一次快照**(不是每块),否则忙碌的终端会把设置页重渲染到卡死;设置页通过 `subscribeHighlightStats` 订阅,多出「命中/耗时」两列(耗时按 µs/ms 格式化)
|
||||
- 性能护栏(别拆):`MAX_CHUNK` 512KB 整块跳过、`MAX_LINE_LEN` 4KB 超长行不跑规则(挡 `(a+)+b` 这类回溯)、`MAX_PER_RULE` 300 每条规则每块上限。实测 180–200KB 混合输出:**全预设 4–9ms/块**(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、**basic 档 ~1.3ms/块(≈49µs/chunk)**、**off 档 0**;单条规则最贵的是 `http`(0.82ms)、`percent`、`danger`
|
||||
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(danger/secret/okstate/warnstate/badstate 这 5 条),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
|
||||
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(预设里是 danger/secret/okstate/warnstate/badstate 这 5 条;用户可在规则编辑器里用「基础规则」开关给自建规则打标),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
|
||||
- 按主机绑定规则集(`settings.terminal.highlightPerHost`,默认关):`HighlightProfile { id, name, ruleIds }` 存在 `AppSettings.highlightProfiles`(顶层数组,仿 `customThemes`),清洗在 `src/shared/highlightProfiles.ts`;`ruleIds` **为空 = 全部规则**,`rulesForProfile` 对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,`excludedByProfile` 给设置页算「这个 profile 排除了哪些规则」。绑定存在 **`connections.json`**(`SshConnection.highlightProfileId`)而不是 settings,`TerminalView` 只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:`PUBLIC_KEYS`(update 路径靠它回写)、`toPublic()`、`saveConnection()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失
|
||||
- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
|
||||
- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?<!not\s)` 反向断言,把 `not ok` 让给 `badstate`
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# OpenTerminal Changelog
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### Input Method Fixes
|
||||
- **Fixed the Chinese IME candidate window not following the cursor**: typing Chinese in the terminal (especially in fast-repainting TUI apps such as Kimi Code or Claude Code) previously made the candidate window drift far away from the caret; it now appears right next to it. The bundled terminal component was upgraded to a version containing the upstream IME fixes.
|
||||
|
||||
### Lock Screen Hardening
|
||||
- **The application menu is removed while locked**: the Alt-revealed menu exposes Reload / DevTools / Zoom items that mouse clicks could use to bypass keyboard interception, so the whole menu is detached during lock and rebuilt on unlock.
|
||||
- The Ctrl+L instant-lock key classification was extracted into a dedicated module with 29 table-driven test cases to prevent regressions.
|
||||
- The global show/hide shortcut recorder now reserves Ctrl+L, so a global registration can no longer silently disable the panic lock at the OS level.
|
||||
- Fixed: after system suspend or window focus loss, the lock screen cooldown countdown could outlive its real deadline, keeping the password input disabled for longer than necessary.
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### Update mechanism change
|
||||
- **GitHub is now the primary update source**: update checks go to GitHub releases first (following the system proxy), gated by a 20-second connectivity probe — without a proxy, or when GitHub is unreachable, the app does not hang but falls back to the domestic update channel (forced direct connection).
|
||||
- **GitHub releases carry the installer assets again** (exe + blockmap + latest.yml), in step with the domestic channel.
|
||||
|
||||
### Terminal highlighting: performance and compatibility
|
||||
- **Faster engine**: colour sequences are precomputed at compile time (no per-span hex parsing); span claiming moved from a linear scan to sorted insertion (binary search); escape-free text floods (e.g. cat of a large file) skip redundant rescans. Measured on 200KB payloads: ~19–56% faster on match-dense output, ~54% faster on plain streamed text, with output byte-identical to the previous version.
|
||||
- **Broader escape-sequence coverage**: DCS/APC/PM/SOS (tmux passthrough, sixel, kitty graphics protocol) and DEC single-character sequences (save/restore cursor, etc.) are now recognised, so normal text on the same line no longer loses its highlighting in those scenarios.
|
||||
|
||||
### Highlight settings page fixes
|
||||
- The "group by category" view now actually clusters by category (it was previously flattened back to priority order).
|
||||
- New "basic rule" switch in the rule editor: your own rules can join the set that stays active in "basic only" mode.
|
||||
- The profile editor lists the rules a partial selection leaves out.
|
||||
- "Replace current rules" imports now ask for confirmation, and the dialog defaults back to "append" each time it opens.
|
||||
- Assorted small fixes: rapid toggles could lose a change, value-band rows were not kept sorted by threshold, the colour field lingered after clearing a background, and more.
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **Fixed a serious v1.0.17 regression: the password could not be typed on the lock screen**. v1.0.17 swallowed all keyboard input at the renderer level while locked (meant to block zoom/reload shortcuts), but the default action of a key press is exactly "insert the character into the focused field" — so the lock screen's password box received nothing and the app could not be unlocked.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# OpenTerminal 更新履歴
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### 入力メソッド修正
|
||||
- **中国語 IME の候補ウィンドウがカーソルに追従しない問題を修正**:ターミナルで中国語を入力する際(特に Kimi Code や Claude Code など高頻度で再描画される TUI では)、候補ウィンドウがカーソルから大きく離れた位置に表示されていましたが、カーソルのすぐそばに表示されるようになりました。同梱のターミナルコンポーネントを、上流の IME 修正を含むバージョンに更新しました。
|
||||
|
||||
### ロック画面の強化
|
||||
- **ロック中はアプリケーションメニューを除去**:Alt キーで表示されるメニューの「再読み込み / 開発者ツール / ズーム」はマウスクリックでキー拦截を回避できたため、ロック中はメニュー全体を外し、ロック解除後に自動復元します。
|
||||
- Ctrl+L 即時ロックのキー判定を独立モジュールに切り出し、29 件のテストケースを追加して回帰を防止。
|
||||
- グローバル表示/非表示ショートカットの録画面で Ctrl+L を予約済みとして使用不可にし、OS レベルでのグローバル登録が緊急ロックを黙って無効化しないようにしました。
|
||||
- 修正:システム休止やウィンドウフォーカス喪失後、ロック画面のパスワード入力クールダウンが実際の期限を超えて続き、入力可能になるまでの時間が不必要に長くなることがあった問題。
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### アップデート機構の変更
|
||||
- **更新元を GitHub 優先に変更**:更新チェックはまず GitHub releases を使い(システムプロキシに追随)、20 秒の接続プローブ付き——プロキシが無い、または GitHub に届かない場合は待たされず、国内更新チャンネル(常時直连)へ自動でフォールバックします。
|
||||
- **GitHub releases でインストーラー同梱を再開**(exe + blockmap + latest.yml)。国内チャンネルと同じ内容です。
|
||||
|
||||
### ターミナルハイライト:性能と互換性
|
||||
- **エンジン高速化**:色シーケンスをコンパイル時に事前計算(span ごとの hex 解析を廃止)、span 確定を線形スキャンからソート済み挿入(二分探索)へ、エスケープを含まないテキスト洪水(大きなファイルの cat 等)では再スキャンをスキップ。200KB 負荷の実測:マッチ密集シーンで約 19–56%、プレーンテキストのストリームで約 54% 高速化。出力は旧版とバイト単位で同一です。
|
||||
- **エスケープシーケンスのカバー拡大**:DCS/APC/PM/SOS(tmux パススルー、sixel、kitty グラフィクス)と DEC 単一文字シーケンス(カーソル保存/復帰など)を認識し、これらの場面で同じ行の通常テキストがハイライトを失わなくなりました。
|
||||
|
||||
### ハイライト設定ページの修正
|
||||
- 「分類でグループ化」ビューが実際に分類ごとにまとまるように(以前は優先度ソートで打散されていました)。
|
||||
- ルールエディターに「基本ルール」スイッチを追加:自作ルールを「基本のみ」モードの有効セットに加えられます。
|
||||
- プロファイルエディターで一部のルールだけを選んだ場合、含まれないルールの一覧を表示。
|
||||
- 「現在のルールを置き換える」インポートに確認ダイアログを追加し、ダイアログを開くたびに「追加」モードへ戻るように。
|
||||
- その他の小さな修正:連続トグルで変更が失われる問題、値バンド行がしきい値順にならない問題、背景色クリア後にカラー欄が残る問題など。
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **v1.0.17 の重大なリグレッションを修正:ロック画面でパスワードを入力できない**。v1.0.17 ではロック中、レンダラー側ですべてのキーボード入力を握りつぶしていました(ズーム/再読み込みのショートカットを無効化する意図でした)。しかしキー入力の既定動作は「フォーカスされたフィールドへの文字挿入」であり、その結果、ロック画面のパスワード欄に何も入力できず、ロック解除もできなくなっていました。
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# OpenTerminal 更新日志
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### 输入法修复
|
||||
- **修复中文输入法候选窗不跟随光标**:此前在终端里输入中文时(尤其是 Kimi Code、Claude Code 等高频刷新的 TUI 界面),候选窗会漂到远离光标的位置;现已紧贴光标显示。内置终端组件升级至包含上游输入法修复的版本。
|
||||
|
||||
### 锁屏加固
|
||||
- **锁定期间移除应用菜单**:Alt 键唤出的菜单里「重新加载 / 开发者工具 / 缩放」可通过鼠标点击绕过键盘拦截,锁定期间整个菜单摘除,解锁后自动恢复。
|
||||
- Ctrl+L 立即锁屏的按键判定重构为独立模块并补充 29 条测试用例,防止回归。
|
||||
- 全局唤起快捷键的录制器保留 Ctrl+L 不允许占用,避免全局注册在系统层静默禁用紧急锁屏。
|
||||
- 修复:系统休眠或窗口失焦后,锁屏密码框的冷却倒计时可能超过真实截止时间,导致可输入时间被无谓延长。
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### 更新机制调整
|
||||
- **更新源改为 GitHub 优先**:检查更新先走 GitHub releases(跟随系统代理),并带 20 秒连通性探测——没有代理或连不通时不会干等,自动改用国内更新通道(强制直连)兜底。
|
||||
- **GitHub releases 恢复随版本发布安装包**(exe + blockmap + latest.yml),与国内通道保持一致。
|
||||
|
||||
### 终端高亮:性能与兼容性
|
||||
- **引擎提速**:颜色序列改为编译期预计算(不再逐处解析 hex);高亮区间认领从线性扫描改为有序插入(二分查找);纯文本洪流(如 cat 大文件)跳过重扫描。实测 200KB 负载:密集匹配场景提速约 19–56%,纯文本流式提速约 54%,输出与旧版逐字节一致。
|
||||
- **转义序列覆盖更全**:新增 DCS/APC/PM/SOS(tmux 透传、sixel、kitty 图形协议)与 DEC 单字符序列(保存/恢复光标等)的识别,这些场景下同行的正常文本不再丢失高亮。
|
||||
|
||||
### 高亮设置页修复
|
||||
- 「按分类分组」视图现在真正按分类聚簇(此前会被优先级排序打散)。
|
||||
- 规则编辑器新增「基础规则」开关:可将自建规则加入「仅基础」模式的生效集合。
|
||||
- 规则集(profile)编辑器勾选部分规则时,会列出未包含的规则。
|
||||
- 「替换现有规则」导入增加确认提示,且每次打开弹窗默认回到「追加」。
|
||||
- 修复若干小问题:快速连续开关规则可能丢改动、数值分级行不按阈值排序、清除背景色后色块残留等。
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **修复 v1.0.17 的严重回归:锁屏后无法输入密码**。v1.0.17 在锁屏期间由渲染层拦截了全部键盘输入(本意是挡住缩放/刷新快捷键),但按键的默认行为正是「向聚焦的输入框插入字符」,导致锁屏界面密码框收不到任何输入、无法解锁。
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# OpenTerminal 更新日誌
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### 輸入法修復
|
||||
- **修復中文輸入法候選窗不跟隨游標**:此前在終端裡輸入中文時(尤其是 Kimi Code、Claude Code 等高頻刷新的 TUI 介面),候選窗會漂到遠離游標的位置;現已緊貼游標顯示。內建終端元件升級至包含上游輸入法修復的版本。
|
||||
|
||||
### 鎖屏加固
|
||||
- **鎖定期間移除應用選單**:Alt 鍵喚出的選單裡「重新載入 / 開發者工具 / 縮放」可透過滑鼠點擊繞過鍵盤攔截,鎖定期間整個選單摘除,解鎖後自動恢復。
|
||||
- Ctrl+L 立即鎖屏的按鍵判定重構為獨立模組並補充 29 條測試案例,防止回歸。
|
||||
- 全域喚起快捷鍵的錄製器保留 Ctrl+L 不允許佔用,避免全域註冊在系統層靜默停用緊急鎖屏。
|
||||
- 修復:系統休眠或視窗失焦後,鎖屏密碼框的冷卻倒計時可能超過真實截止時間,導致可輸入時間被無謂延長。
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### 更新機制調整
|
||||
- **更新來源改為 GitHub 優先**:檢查更新先走 GitHub releases(跟隨系統代理),並帶 20 秒連通性探測——沒有代理或連不通時不會空等,自動改用國內更新通道(強制直連)兜底。
|
||||
- **GitHub releases 恢復隨版本發佈安裝包**(exe + blockmap + latest.yml),與國內通道保持一致。
|
||||
|
||||
### 終端高亮:效能與相容性
|
||||
- **引擎提速**:顏色序列改為編譯期預計算(不再逐處解析 hex);高亮區間認領從線性掃描改為有序插入(二分搜尋);純文字洪流(如 cat 大檔案)跳過重複掃描。實測 200KB 負載:密集匹配場景提速約 19–56%,純文字流式提速約 54%,輸出與舊版逐位元組一致。
|
||||
- **跳脫序列覆蓋更全**:新增 DCS/APC/PM/SOS(tmux 透傳、sixel、kitty 圖形協定)與 DEC 單字元序列(儲存/恢復游標等)的辨識,這些場景下同行的正常文字不再遺失高亮。
|
||||
|
||||
### 高亮設定頁修復
|
||||
- 「依分類分組」檢視現在真正依分類聚簇(此前會被優先級排序打散)。
|
||||
- 規則編輯器新增「基礎規則」開關:可將自建規則加入「僅基礎」模式的生效集合。
|
||||
- 規則集(profile)編輯器勾選部分規則時,會列出未包含的規則。
|
||||
- 「取代現有規則」匯入增加確認提示,且每次開啟視窗預設回到「附加」。
|
||||
- 修復若干小問題:快速連續開關規則可能遺失改動、數值分級列不按閾值排序、清除背景色後色塊殘留等。
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **修復 v1.0.17 的嚴重回歸:鎖定後無法輸入密碼**。v1.0.17 在鎖定期間由繪製程序攔截了全部鍵盤輸入(本意是擋住縮放/重新整理快捷鍵),但按鍵的預設行為正是「向聚焦的輸入框插入字元」,導致鎖定畫面密碼框收不到任何輸入、無法解鎖。
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前版本与仓库
|
||||
|
||||
- v1.0.13,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- v1.0.19,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- 开源协议:MIT(LICENSE)
|
||||
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
|
||||
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
## 发布流程(下一版本照抄)
|
||||
|
||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)
|
||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 末尾会 `npm install --package-lock-only` 自动同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
|
||||
2. `node scripts/release.cjs <版本号> --skip-github`:脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap
|
||||
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(同一版本可重复运行:release 复用、已传资产跳过)
|
||||
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||
@@ -88,6 +88,7 @@ node tests/sysinfo-e2e.mjs
|
||||
8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发
|
||||
9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2)
|
||||
10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘
|
||||
11. **`@xterm/xterm` 精确锁定 `6.1.0-beta.304`(不带 ^)**:修中文 IME 候选窗不跟随光标(上游 xtermjs/xterm.js#5759 compositionstart 时同步 textarea 位置 + #5747 composition-view 限宽),stable 6.0.0 未带这两个修复;待 6.1.0 stable 发布后改回 `^` 语义化范围
|
||||
|
||||
## 编排约定
|
||||
|
||||
@@ -98,5 +99,6 @@ node tests/sysinfo-e2e.mjs
|
||||
## 待办(按优先级)
|
||||
|
||||
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
|
||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键
|
||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键、中文 IME 候选窗跟随光标(每次发版前手测一遍,CI 无法覆盖)
|
||||
3. `@xterm/xterm` 6.1.0 stable 发布后把精确锁定的 `6.1.0-beta.304` 改回 `^` 范围并回归验证 IME(背景见已踩坑 11)
|
||||
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/xterm": "6.1.0-beta.304",
|
||||
"antd": "^6.6.2",
|
||||
"dockview-react": "^8.2.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
@@ -2855,9 +2855,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"version": "6.1.0-beta.304",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.1.0-beta.304.tgz",
|
||||
"integrity": "sha512-Wq9d4qFYslYQBIan+riXotdyurtREc6v+HWdAwV6Y2JSDI9eJ1dkWm/fzYb2rCGgGnM3baiYuSK4Z6OLNKLzDw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.20",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "CodingPlan.Site",
|
||||
@@ -13,8 +13,8 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"pretest": "npm run typecheck",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/lock-store.mjs && node tests/lock-controller.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",
|
||||
"predist": "npm test",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.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",
|
||||
"predist": "npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/xterm": "6.1.0-beta.304",
|
||||
"antd": "^6.6.2",
|
||||
"dockview-react": "^8.2.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@ async function github() {
|
||||
// Idempotent like gitea(): a re-run after a partial publish must reuse the
|
||||
// release it created last time instead of dying on 422 already_exists and
|
||||
// leaving GitHub permanently half-uploaded until someone deletes it by hand.
|
||||
const byTag = (): Promise<Response> => gh(`${api}/releases/tags/v${V}`)
|
||||
const byTag = () => gh(`${api}/releases/tags/v${V}`)
|
||||
let resp = await byTag()
|
||||
let rel
|
||||
if (resp.ok) {
|
||||
|
||||
+11
-37
@@ -7,6 +7,8 @@ import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||
import { killAllPtys, killPtysByOwner } from './pty'
|
||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||
import { getLockController, initLockController } from './lockController'
|
||||
import { applyMenuLockState } from './lockMenu'
|
||||
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
@@ -92,8 +94,10 @@ if (!gotSingleInstanceLock) {
|
||||
// The lock state has to exist before the window loads, so a lockAtStartup
|
||||
// lock is already in place when the renderer asks for it. It also starts the
|
||||
// idle watcher, which is why it belongs after ready: powerMonitor cannot be
|
||||
// touched before that.
|
||||
initLockController()
|
||||
// touched before that. A restored startup lock engages before any publish,
|
||||
// so the menu teardown is applied here from the flag itself.
|
||||
const lock = initLockController()
|
||||
applyMenuLockState(lock.isLocked())
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
@@ -106,31 +110,6 @@ if (!gotSingleInstanceLock) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerators that must not reach the page while the lock screen is up.
|
||||
*
|
||||
* The overlay is a DOM layer inside the window, so everything the browser
|
||||
* process handles on its own passes straight through it: reloading the renderer
|
||||
* runs Workspace's beforeunload and kills every local/SSH session behind the
|
||||
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
|
||||
* resized or read. These come from Electron's default application menu, whose
|
||||
* keys are matched before any renderer code runs.
|
||||
*
|
||||
* Matching is on `input.key` rather than `input.code`: the key is what the
|
||||
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
|
||||
* while the code depends on the physical key.
|
||||
*/
|
||||
function isLockBlockedShortcut(input: Electron.Input): boolean {
|
||||
const key = input.key.toLowerCase()
|
||||
if (key === 'f5') return true
|
||||
if (!input.control) return false
|
||||
if (key === 'r') return true
|
||||
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
|
||||
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
|
||||
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
|
||||
return input.shift && (key === 'i' || key === 'j' || key === 'c')
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
|
||||
// (electron-builder embeds build/icon.png), so undefined is fine there.
|
||||
@@ -169,7 +148,9 @@ function createWindow(): void {
|
||||
// Ctrl+L is the panic lock: it must work from anywhere in the app, terminals
|
||||
// included, so it is captured here ahead of the page. It only takes the chord
|
||||
// away when a lock actually engages — an unconfigured app keeps Ctrl+L for
|
||||
// the shell's clear-screen.
|
||||
// the shell's clear-screen. Auto-repeats are skipped: the first keydown
|
||||
// decides, and on an unconfigured app each repeat would otherwise re-read
|
||||
// lock.json + settings.json from disk (~30 keydown/s while held).
|
||||
// Swallow the menu accelerators that would otherwise act behind the lock
|
||||
// overlay (see isLockBlockedShortcut). A throw in here would break typing
|
||||
// altogether, so the whole guard is defensive.
|
||||
@@ -177,15 +158,8 @@ function createWindow(): void {
|
||||
try {
|
||||
if (input.type !== 'keyDown') return
|
||||
const lock = getLockController()
|
||||
if (
|
||||
!lock.isLocked() &&
|
||||
input.control &&
|
||||
!input.shift &&
|
||||
!input.alt &&
|
||||
!input.meta &&
|
||||
input.key.toLowerCase() === 'l'
|
||||
) {
|
||||
if (lock.lockNow().locked) event.preventDefault()
|
||||
if (!lock.isLocked() && isPanicLockChord(input)) {
|
||||
if (!input.isAutoRepeat && lock.lockNow().locked) event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!lock.isLocked()) return
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isValidPassword
|
||||
} from './lockStore'
|
||||
import { loadSettings, mutateSettings } from './settingsStore'
|
||||
import { applyMenuLockState } from './lockMenu'
|
||||
|
||||
/**
|
||||
* Backoff after each failed verification: the nth failure refuses further
|
||||
@@ -90,7 +91,14 @@ export class LockController {
|
||||
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
|
||||
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
|
||||
this.publish =
|
||||
options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
|
||||
options.publish ??
|
||||
((state): void => {
|
||||
// The menu teardown rides every published transition; the startup lock
|
||||
// engages without publishing, so index.ts applies it once from the
|
||||
// restored flag directly.
|
||||
applyMenuLockState(state.locked)
|
||||
broadcast(Ipc.LOCK_STATE_CHANGED, state)
|
||||
})
|
||||
this.now = options.now ?? ((): number => Date.now())
|
||||
this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
|
||||
this.clearLockPreferences =
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Menu } from 'electron'
|
||||
|
||||
/**
|
||||
* Remove the application menu while the lock screen is up, restore it on unlock.
|
||||
*
|
||||
* `before-input-event` only sees the keyboard: with the default menu in place,
|
||||
* pressing Alt reveals the hidden menu bar (`autoHideMenuBar`) and a mouse click
|
||||
* on View → Reload / Toggle Developer Tools / Zoom still runs behind — or
|
||||
* against — the opaque overlay. Reload kills every session behind the mask via
|
||||
* beforeunload; DevTools makes the hidden DOM readable. Neither is reachable
|
||||
* once the menu is gone, and the lock's keyboard guard (lockShortcuts.ts) keeps
|
||||
* covering the chords in dev, where the menu is the developer's tool.
|
||||
*
|
||||
* The restore template mirrors Electron's own default menu (default-menu.ts):
|
||||
* the app never installs a custom one, so this rebuilds exactly what was there.
|
||||
*/
|
||||
let menuRemoved = false
|
||||
|
||||
export function applyMenuLockState(locked: boolean): void {
|
||||
if (menuRemoved === locked) return
|
||||
menuRemoved = locked
|
||||
if (locked) {
|
||||
Menu.setApplicationMenu(null)
|
||||
return
|
||||
}
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(process.platform === 'darwin'
|
||||
? [{ role: 'appMenu' as const }]
|
||||
: []),
|
||||
{ role: 'fileMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' }
|
||||
]
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Lock-screen keyboard classification, kept free of Electron imports so the
|
||||
* decision logic can be table-tested under plain Node (tests/lock-shortcuts.mjs).
|
||||
* `Electron.Input` satisfies this shape structurally.
|
||||
*/
|
||||
|
||||
export interface LockInputEvent {
|
||||
key: string
|
||||
control: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
meta: boolean
|
||||
isAutoRepeat?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Accelerators that must not reach the page while the lock screen is up.
|
||||
*
|
||||
* The overlay is a DOM layer inside the window, so everything the browser
|
||||
* process handles on its own passes straight through it: reloading the renderer
|
||||
* runs Workspace's beforeunload and kills every local/SSH session behind the
|
||||
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
|
||||
* resized or read. These chords come from Electron's default application menu,
|
||||
* whose keys are matched before any renderer code runs (the menu itself is
|
||||
* removed while locked — see lockMenu.ts — so its mouse-clickable items cannot
|
||||
* be reached either).
|
||||
*
|
||||
* Matching is on `input.key` rather than `input.code`: the key is what the
|
||||
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
|
||||
* while the code depends on the physical key.
|
||||
*/
|
||||
export function isLockBlockedShortcut(input: LockInputEvent): boolean {
|
||||
const key = input.key.toLowerCase()
|
||||
if (key === 'f5') return true
|
||||
if (!input.control) return false
|
||||
if (key === 'r') return true
|
||||
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
|
||||
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
|
||||
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
|
||||
return input.shift && (key === 'i' || key === 'j' || key === 'c')
|
||||
}
|
||||
|
||||
/**
|
||||
* The panic lock chord: exactly Ctrl+L, no other modifier. Shift is excluded
|
||||
* because Ctrl+Shift+L is the switch-to-English chord on Chinese and Japanese
|
||||
* IMEs; Alt is excluded because AltGr arrives as Ctrl+Alt on most European
|
||||
* layouts. A chord that isn't exactly this must keep its original meaning.
|
||||
*/
|
||||
export function isPanicLockChord(input: LockInputEvent): boolean {
|
||||
return (
|
||||
input.control &&
|
||||
!input.shift &&
|
||||
!input.alt &&
|
||||
!input.meta &&
|
||||
input.key.toLowerCase() === 'l'
|
||||
)
|
||||
}
|
||||
+53
-14
@@ -8,9 +8,11 @@ import { loadSettings } from './settingsStore'
|
||||
import { markQuitting } from './tray'
|
||||
|
||||
/**
|
||||
* Update feeds: domestic Gitea generic package is tried first (fast in
|
||||
* China); GitHub releases is the fallback. Both feeds carry the NSIS .exe —
|
||||
* electron-updater does NOT support MSI auto-updates, MSI is manual only.
|
||||
* Update feeds: GitHub releases is tried first (system proxy, gated by a
|
||||
* 20s connectivity probe so proxy-less users don't hang); the domestic
|
||||
* Gitea generic package is the fallback (forced direct). Both feeds carry
|
||||
* the NSIS .exe — electron-updater does NOT support MSI auto-updates, MSI
|
||||
* is manual only.
|
||||
*/
|
||||
const GITEA_FEED =
|
||||
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
|
||||
@@ -19,6 +21,9 @@ const GITEA_RELEASES_API =
|
||||
const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' }
|
||||
const GITHUB_RELEASES_API =
|
||||
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
|
||||
const GITHUB_PROBE_URL =
|
||||
'https://api.github.com/repos/billowliu2/OpenTerminal/releases/latest'
|
||||
const GITHUB_PROBE_TIMEOUT_MS = 20_000
|
||||
|
||||
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
|
||||
let activeFeed: 'gitea' | 'github' = 'gitea'
|
||||
@@ -70,23 +75,38 @@ function wireEvents(): void {
|
||||
)
|
||||
}
|
||||
|
||||
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
|
||||
/**
|
||||
* Check updates: GitHub releases first (system proxy, gated by a
|
||||
* connectivity probe), domestic Gitea generic package as fallback.
|
||||
*
|
||||
* The feed is decided BEFORE touching the updater: checkForUpdates has no
|
||||
* timeout, and racing it while mid-flight risks two concurrent checks
|
||||
* polluting state.
|
||||
*/
|
||||
async function checkWithFallback(): Promise<void> {
|
||||
// The feed choice is per attempt: a transient Gitea failure must not pin
|
||||
// every later check to GitHub (and its system proxy) for the whole session.
|
||||
// The feed choice is per attempt: a transient GitHub failure must not pin
|
||||
// every later check to Gitea (and its forced direct connection) — or vice
|
||||
// versa — for the whole session.
|
||||
let githubErr: string | undefined
|
||||
if (await probeGithub()) {
|
||||
useFeed('github')
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('[updater] github feed failed, falling back to gitea:', err)
|
||||
githubErr = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
} else {
|
||||
console.warn('[updater] github probe failed or timed out, using gitea feed directly')
|
||||
}
|
||||
useFeed('gitea')
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
console.warn('[updater] gitea feed failed, falling back to github:', err)
|
||||
if (githubErr === undefined) throw err
|
||||
const giteaErr = err instanceof Error ? err.message : String(err)
|
||||
useFeed('github')
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err2) {
|
||||
const ghErr = err2 instanceof Error ? err2.message : String(err2)
|
||||
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: ghErr }))
|
||||
}
|
||||
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: githubErr }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +141,25 @@ async function directFetch(url: string): Promise<Response> {
|
||||
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectivity probe for the GitHub feed: dedicated session in the same
|
||||
* proxy mode as the feed (system), hard timeout so proxy-less users fall
|
||||
* back to Gitea instead of hanging on a dead proxy/DNS.
|
||||
*/
|
||||
async function probeGithub(): Promise<boolean> {
|
||||
try {
|
||||
const s = session.fromPartition('openterminal-github-probe', { cache: false })
|
||||
await s.setProxy({ mode: 'system' })
|
||||
const resp = await s.fetch(GITHUB_PROBE_URL, {
|
||||
headers: { 'User-Agent': 'OpenTerminal' },
|
||||
signal: AbortSignal.timeout(GITHUB_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
return resp.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changelog sources, in order:
|
||||
* 1. update channel's release-notes.md — the Gitea repo itself is private
|
||||
|
||||
@@ -68,6 +68,23 @@ export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX
|
||||
return () => window.clearInterval(id)
|
||||
}, [cooldownUntil])
|
||||
|
||||
// The countdown is renderer-driven, and Chromium may suspend or coalesce
|
||||
// timers while the window is hidden, minimized or occluded — a suspended
|
||||
// interval would leave the input disabled (and the "retry in N s" text
|
||||
// frozen) long past the real deadline. Re-sync the clock the moment the
|
||||
// page becomes visible or focused again, so recovery is immediate.
|
||||
useEffect(() => {
|
||||
const sync = (): void => setNow(Date.now())
|
||||
document.addEventListener('visibilitychange', sync)
|
||||
window.addEventListener('focus', sync)
|
||||
window.addEventListener('pageshow', sync)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', sync)
|
||||
window.removeEventListener('focus', sync)
|
||||
window.removeEventListener('pageshow', sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingMs = Math.max(0, cooldownUntil - now)
|
||||
const cooling = remainingMs > 0
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
|
||||
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
|
||||
import type { ImportError } from '@shared/highlightIO'
|
||||
@@ -40,6 +40,7 @@ export function HighlightImportExport({
|
||||
setDraft('')
|
||||
setResult(null)
|
||||
setCopied(false)
|
||||
setMode('append')
|
||||
}, [open])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
@@ -83,7 +84,9 @@ export function HighlightImportExport({
|
||||
setResult({ warnings: parsed.warnings, count: 0 })
|
||||
return
|
||||
}
|
||||
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
|
||||
// read at call time: the render-scoped array goes stale inside a React batch
|
||||
const current = useSettingsStore.getState().settings.highlightRules
|
||||
await setHighlightRules(mergeRules(current, parsed.rules, mode))
|
||||
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
|
||||
}
|
||||
|
||||
@@ -145,14 +148,30 @@ export function HighlightImportExport({
|
||||
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
|
||||
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={draft.trim() === ''}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t('settings.highlight.importOk')}
|
||||
</Button>
|
||||
{mode === 'replace' ? (
|
||||
// replace wipes the whole set, so it gets the same guard as deletes
|
||||
<Popconfirm
|
||||
title={t('settings.highlight.importReplaceTitle')}
|
||||
description={t('settings.highlight.importReplaceDesc')}
|
||||
okText={t('settings.highlight.importOk')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void handleImport()}
|
||||
>
|
||||
<Button size="small" type="primary" disabled={draft.trim() === ''}>
|
||||
{t('settings.highlight.importOk')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={draft.trim() === ''}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t('settings.highlight.importOk')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{result && (
|
||||
<Alert
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { t } from '@shared/i18n'
|
||||
import { excludedByProfile } from '@shared/highlightProfiles'
|
||||
import type { HighlightProfile } from '@shared/settings'
|
||||
import { ruleLabel } from './ruleLabel'
|
||||
import { useSettingsStore } from './store'
|
||||
@@ -39,11 +40,15 @@ export function HighlightProfiles(): React.JSX.Element {
|
||||
if (name.trim() === '') return
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = editing
|
||||
? profiles.map((profile) =>
|
||||
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
|
||||
// read at call time: the render-scoped array goes stale inside a React
|
||||
// batch, and two writes in one batch would silently drop the first
|
||||
const current = useSettingsStore.getState().settings.highlightProfiles
|
||||
const target = editingId !== null && editingId !== '' ? current.find((profile) => profile.id === editingId) : undefined
|
||||
const next = target
|
||||
? current.map((profile) =>
|
||||
profile.id === target.id ? { ...profile, name: name.trim(), ruleIds } : profile
|
||||
)
|
||||
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
|
||||
: [...current, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
|
||||
await setHighlightProfiles(next)
|
||||
setEditingId(null)
|
||||
} finally {
|
||||
@@ -51,6 +56,16 @@ export function HighlightProfiles(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the current selection leaves out, shown under the checkboxes so the
|
||||
* user sees which rules a host bound to this profile will NOT run. An empty
|
||||
* selection means "all rules", so nothing is excluded by definition.
|
||||
*/
|
||||
const excluded =
|
||||
ruleIds.length > 0 && ruleIds.length < rules.length
|
||||
? excludedByProfile(rules, { id: editingId ?? '', name, ruleIds })
|
||||
: []
|
||||
|
||||
const columns: ColumnsType<HighlightProfile> = [
|
||||
{
|
||||
title: t('settings.highlight.profileName'),
|
||||
@@ -84,7 +99,11 @@ export function HighlightProfiles(): React.JSX.Element {
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
|
||||
onConfirm={() =>
|
||||
void setHighlightProfiles(
|
||||
useSettingsStore.getState().settings.highlightProfiles.filter((p) => p.id !== record.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
{t('common.delete')}
|
||||
@@ -154,6 +173,14 @@ export function HighlightProfiles(): React.JSX.Element {
|
||||
onChange={(values) => setRuleIds(values as string[])}
|
||||
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
|
||||
/>
|
||||
{excluded.length > 0 && (
|
||||
<span className="hl-editor-hint">
|
||||
{t('settings.highlight.profileExcluded', {
|
||||
n: excluded.length,
|
||||
list: excluded.map((rule) => ruleLabel(rule)).join('; ')
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
import { applyThemeColors } from '../theme/highlightColors'
|
||||
import './highlight.css'
|
||||
|
||||
const ORDERED_SORT: 'ascend' = 'ascend'
|
||||
|
||||
/** preset color swatches for the highlighting editor. */
|
||||
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
|
||||
|
||||
@@ -92,6 +90,7 @@ interface RuleDraft {
|
||||
caseInsensitive: boolean
|
||||
note?: string
|
||||
enabled: boolean
|
||||
basic: boolean
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
@@ -115,6 +114,8 @@ export function HighlightTab(): React.JSX.Element {
|
||||
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
|
||||
// Grouped view: cluster by category, keeping priority order inside a group
|
||||
// (Array.prototype.sort is stable, so the priority sort above survives).
|
||||
// The table must not carry a defaultSortOrder — antd would re-sort the
|
||||
// dataSource globally and undo this clustering.
|
||||
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
|
||||
}, [highlightRules, groupByCategory])
|
||||
|
||||
@@ -123,7 +124,10 @@ export function HighlightTab(): React.JSX.Element {
|
||||
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
|
||||
|
||||
const replaceRule = async (updated: HighlightRule): Promise<void> => {
|
||||
const next = highlightRules.map((r) => (r.id === updated.id ? updated : r))
|
||||
// read at call time: the render-scoped array goes stale inside a React
|
||||
// batch, and two writes in one batch would silently drop the first
|
||||
const current = useSettingsStore.getState().settings.highlightRules
|
||||
const next = current.map((r) => (r.id === updated.id ? updated : r))
|
||||
await setHighlightRules(next)
|
||||
}
|
||||
|
||||
@@ -132,7 +136,8 @@ export function HighlightTab(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string): Promise<void> => {
|
||||
await setHighlightRules(highlightRules.filter((r) => r.id !== id))
|
||||
const current = useSettingsStore.getState().settings.highlightRules
|
||||
await setHighlightRules(current.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
const handleReset = async (): Promise<void> => {
|
||||
@@ -206,7 +211,6 @@ export function HighlightTab(): React.JSX.Element {
|
||||
width: 76,
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.priority - b.priority,
|
||||
defaultSortOrder: ORDERED_SORT,
|
||||
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
|
||||
},
|
||||
{
|
||||
@@ -217,8 +221,8 @@ export function HighlightTab(): React.JSX.Element {
|
||||
render: (color: HighlightRule['color'], record) =>
|
||||
record.bands && record.bands.length > 0 ? (
|
||||
<div className="hl-band-preview">
|
||||
{record.bands.map((band) => (
|
||||
<Tooltip key={band.min} title={`≥ ${band.min}`}>
|
||||
{record.bands.map((band, index) => (
|
||||
<Tooltip key={index} title={`≥ ${band.min}`}>
|
||||
<span className="hl-preview-text" style={{ color: band.fg }}>
|
||||
≥{band.min}
|
||||
</span>
|
||||
@@ -423,7 +427,8 @@ function HighlightEditor({
|
||||
bg: undefined,
|
||||
caseInsensitive: false,
|
||||
note: undefined,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
basic: false
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showBg, setShowBg] = useState(false)
|
||||
@@ -485,9 +490,10 @@ function HighlightEditor({
|
||||
const note = editing ? editing.note : undefined
|
||||
const enabled = editing ? editing.enabled : true
|
||||
const caseInsensitive = editing ? editing.caseInsensitive === true : false
|
||||
const basic = editing ? editing.basic === true : false
|
||||
const bands = editing?.bands
|
||||
const category = editing?.category
|
||||
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
|
||||
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled, basic })
|
||||
setShowBg(bg != null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
@@ -507,7 +513,12 @@ function HighlightEditor({
|
||||
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
|
||||
const bands = draft.bands
|
||||
if (!bands) return
|
||||
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
|
||||
const next = bands.map((band, i) => (i === index ? { ...band, ...partial } : band))
|
||||
// the engine applies bands in ascending min order (compileBands sorts), so
|
||||
// keep the editor rows in that same order — a stable sort keeps equal mins
|
||||
// in their entry order
|
||||
next.sort((a, b) => a.min - b.min)
|
||||
patch({ bands: next })
|
||||
}
|
||||
|
||||
const removeBand = (index: number): void => {
|
||||
@@ -535,6 +546,7 @@ function HighlightEditor({
|
||||
bands,
|
||||
category: draft.category,
|
||||
caseInsensitive,
|
||||
...(draft.basic ? { basic: true as const } : {}),
|
||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||
}
|
||||
await setHighlightRules([...highlightRules, rule])
|
||||
@@ -548,6 +560,9 @@ function HighlightEditor({
|
||||
bands,
|
||||
category: draft.category,
|
||||
caseInsensitive,
|
||||
// explicit override (not a conditional spread): unchecking must clear
|
||||
// a basic flag the edited rule may already carry
|
||||
basic: draft.basic ? true : undefined,
|
||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||
}
|
||||
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
|
||||
@@ -646,7 +661,13 @@ function HighlightEditor({
|
||||
value={draft.bg ?? PRESET_COLORS[0]}
|
||||
onChange={(v) => patch({ bg: v })}
|
||||
/>
|
||||
<Button size="small" onClick={() => patch({ bg: undefined })}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
patch({ bg: undefined })
|
||||
setShowBg(false)
|
||||
}}
|
||||
>
|
||||
{t('settings.highlight.clearBg')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -742,6 +763,12 @@ function HighlightEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.basic')}</span>
|
||||
<Switch checked={draft.basic} onChange={(c) => patch({ basic: c })} />
|
||||
<span className="hl-editor-hint">{t('settings.highlight.basicHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
|
||||
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
|
||||
|
||||
@@ -500,8 +500,17 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
|
||||
*/
|
||||
const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown'])
|
||||
|
||||
/**
|
||||
* Whole chords the app owns outright, matched exactly (modifier set included).
|
||||
* Ctrl+L is the panic lock, captured in main's before-input-event: a global
|
||||
* registration intercepts the key at the OS level even while this window is
|
||||
* focused, so binding it here would silently disable the lock shortcut.
|
||||
*/
|
||||
const RESERVED_EXACT_ACCELERATORS = new Set(['Control+L'])
|
||||
|
||||
/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */
|
||||
function isReservedAccelerator(accel: string): boolean {
|
||||
if (RESERVED_EXACT_ACCELERATORS.has(accel)) return true
|
||||
const parts = accel.split('+')
|
||||
return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
|
||||
}
|
||||
|
||||
@@ -386,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [settings.terminal.highlightStats])
|
||||
streamRef.current?.setRules(compiledRules)
|
||||
// Push the latest compiled rules into the stream from an effect — assigning
|
||||
// during render would be a side effect in the render body.
|
||||
useEffect(() => {
|
||||
streamRef.current?.setRules(compiledRules)
|
||||
}, [compiledRules])
|
||||
|
||||
// M5: load completion candidates once per session (history + library cached).
|
||||
const ensureCompletionCache = useCallback((): void => {
|
||||
|
||||
@@ -14,14 +14,18 @@ export interface CompiledRule {
|
||||
id: string
|
||||
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
|
||||
regex: RegExp
|
||||
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
|
||||
/** hex fg, e.g. "#3fb950" — kept for introspection; `open` is precomputed from it */
|
||||
fg: string
|
||||
/** optional hex bg */
|
||||
/** optional hex bg (not re-validated; a malformed value folds to white, as before) */
|
||||
bg?: string
|
||||
/** optional value bands, ascending by `min` — see HighlightRule.bands */
|
||||
bands?: { min: number; fg: string }[]
|
||||
bands?: { min: number; fg: string; open: string }[]
|
||||
/** copied from the source rule for stable priority ordering */
|
||||
priority: number
|
||||
/** precomputed truecolor fg fragment, e.g. "38;2;63;185;80" */
|
||||
fgSgr: string
|
||||
/** full SGR open sequence `\x1b[38;2;…(;48;2;…)?m` incl. optional bg, built once here */
|
||||
open: string
|
||||
}
|
||||
|
||||
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
|
||||
@@ -39,11 +43,26 @@ export interface RuleStat {
|
||||
export type StatsSink = Map<string, RuleStat>
|
||||
|
||||
type Token = { kind: 'seq' | 'text'; value: string }
|
||||
type Span = { start: number; end: number; fg: string; bg?: string }
|
||||
/** A claimed span carries its precomputed SGR open sequence, never a hex colour. */
|
||||
type Span = { start: number; end: number; open: string }
|
||||
|
||||
/** ANSI escape tokens: CSI, OSC, two-char (ESC ( / ) ..), and ESC = / > . */
|
||||
/**
|
||||
* ANSI escape tokens:
|
||||
* - CSI `ESC [ … final`
|
||||
* - OSC `ESC ] … (BEL | ST)`
|
||||
* - DCS/APC/PM/SOS `ESC P|X|^|_ … (BEL | ST)` — tmux, sixel, kitty graphics.
|
||||
* The `[^\x1b]*` body means an unterminated one stops at the next ESC
|
||||
* instead of swallowing the rest of the buffer (it then stays a text run,
|
||||
* which applyHighlights passes through raw).
|
||||
* - two-char charset `ESC ( x` / `ESC ) x`, keypad modes `ESC =` / `ESC >`
|
||||
* - single-char Fe escapes: `ESC` + one 0x30–0x7E byte (DECSC `\x1b7`,
|
||||
* DECRC `\x1b8`, NEL `\x1bE`, RI `\x1bM`, RIS `\x1bc`, orphan ST `\x1b\`, …).
|
||||
* Last in the alternation, and the initiators of the longer forms
|
||||
* (`[ ] ( ) P X ^ _ = >`) are excluded from the class so it can never steal
|
||||
* the leading bytes of a longer sequence.
|
||||
*/
|
||||
const ESCAPE_RE =
|
||||
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g
|
||||
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>]|\x1b[0-9:;<?@A-OQ-WYZ\\`a-z{|}~])/g
|
||||
|
||||
/** Matches a single rule may inject per chunk (bomb guard). */
|
||||
const MAX_PER_RULE = 300
|
||||
@@ -69,6 +88,12 @@ export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }
|
||||
}
|
||||
|
||||
/** "r;g;b" decimal SGR fragment for one hex colour (white fallback via hexToRgb). */
|
||||
function rgbFragment(hex: string): string {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return `${r};${g};${b}`
|
||||
}
|
||||
|
||||
/** Split a chunk into escape-sequence tokens and plain-text runs. */
|
||||
export function tokenize(input: string): Token[] {
|
||||
const out: Token[] = []
|
||||
@@ -110,63 +135,94 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
|
||||
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
|
||||
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
|
||||
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
|
||||
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
|
||||
// bg stays unvalidated on purpose (as before): a malformed hex folds to
|
||||
// white inside hexToRgb, byte-identical to the old wrap-time conversion.
|
||||
const bgPart = bg ? `;48;2;${rgbFragment(bg)}` : ''
|
||||
const fgSgr = rgbFragment(fg)
|
||||
out.push({
|
||||
id: rule.id,
|
||||
regex,
|
||||
fg,
|
||||
bg,
|
||||
bands: compileBands(rule.bands, bgPart),
|
||||
priority: rule.priority,
|
||||
fgSgr,
|
||||
open: `\x1b[38;2;${fgSgr}${bgPart}m`
|
||||
})
|
||||
}
|
||||
out.sort((a, b) => a.priority - b.priority)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
|
||||
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
|
||||
function compileBands(bands: HighlightRule['bands'], bgPart: string): CompiledRule['bands'] {
|
||||
if (!Array.isArray(bands)) return undefined
|
||||
const kept: { min: number; fg: string }[] = []
|
||||
const kept: NonNullable<CompiledRule['bands']> = []
|
||||
for (const band of bands) {
|
||||
if (!band || !Number.isFinite(band.min)) continue
|
||||
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
|
||||
kept.push({ min: band.min, fg: band.fg })
|
||||
kept.push({ min: band.min, fg: band.fg, open: `\x1b[38;2;${rgbFragment(band.fg)}${bgPart}m` })
|
||||
}
|
||||
if (kept.length === 0) return undefined
|
||||
kept.sort((a, b) => a.min - b.min)
|
||||
return kept
|
||||
}
|
||||
|
||||
/** Wrap one plain-text span with truecolor SGR. */
|
||||
function wrap(text: string, fgHex: string, bgHex?: string): string {
|
||||
const f = hexToRgb(fgHex)
|
||||
let s = `\x1b[38;2;${f.r};${f.g};${f.b}`
|
||||
if (bgHex) {
|
||||
const b = hexToRgb(bgHex)
|
||||
s += `;48;2;${b.r};${b.g};${b.b}`
|
||||
}
|
||||
return `${s}m${text}\x1b[0m`
|
||||
}
|
||||
|
||||
/** True when [s,e) overlaps any claimed span. */
|
||||
function overlaps(claimed: Span[], s: number, e: number): boolean {
|
||||
for (const c of claimed) {
|
||||
if (s < c.end && e > c.start) return true
|
||||
}
|
||||
return false
|
||||
/** Wrap one plain-text span with its precomputed SGR open sequence. */
|
||||
function wrap(text: string, open: string): string {
|
||||
return `${open}${text}\x1b[0m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour for one match: with value bands the match's first number picks the
|
||||
* band (last `min` that is <= the value wins); everything else keeps the rule's
|
||||
* own foreground.
|
||||
* Try to claim [s,e) for a span. Claimed spans are pairwise disjoint and kept
|
||||
* sorted by start (ends ascend with starts), which turns the old linear scan
|
||||
* over every claimed span (O(spans²) on match-dense runs) into:
|
||||
* - an O(1) append when the candidate lands past every claim — the common
|
||||
* case, since rule scans sweep a run left to right; otherwise
|
||||
* - one binary search: the only possible clash is with the nearest span left
|
||||
* of the insertion point.
|
||||
* The span colour is only computed on accept. Returns true when claimed.
|
||||
*/
|
||||
function bandColor(rule: CompiledRule, matched: string): string {
|
||||
function claim(claimed: Span[], s: number, e: number, rule: CompiledRule, matched: string): boolean {
|
||||
const n = claimed.length
|
||||
if (n === 0 || claimed[n - 1].end <= s) {
|
||||
claimed.push({ start: s, end: e, open: bandOpen(rule, matched) })
|
||||
return true
|
||||
}
|
||||
let lo = 0
|
||||
let hi = n
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (claimed[mid].start < e) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
if (lo > 0 && claimed[lo - 1].end > s) return false
|
||||
claimed.splice(lo, 0, { start: s, end: e, open: bandOpen(rule, matched) })
|
||||
return true
|
||||
}
|
||||
|
||||
/** First-number finder for band selection; no /g, so the shared literal is stateless. */
|
||||
const BAND_NUMBER_RE = /-?\d+(?:\.\d+)?/
|
||||
|
||||
/**
|
||||
* SGR open sequence for one match: with value bands the match's first number
|
||||
* picks the band (last `min` that is <= the value wins); everything else keeps
|
||||
* the rule's own precomputed open sequence. Pure table lookup — no colour math
|
||||
* on the hot path.
|
||||
*/
|
||||
function bandOpen(rule: CompiledRule, matched: string): string {
|
||||
const bands = rule.bands
|
||||
if (!bands || bands.length === 0) return rule.fg
|
||||
const number = /-?\d+(?:\.\d+)?/.exec(matched)
|
||||
if (number === null) return rule.fg
|
||||
if (!bands || bands.length === 0) return rule.open
|
||||
const number = BAND_NUMBER_RE.exec(matched)
|
||||
if (number === null) return rule.open
|
||||
const value = Number(number[0])
|
||||
if (!Number.isFinite(value)) return rule.fg
|
||||
let fg = rule.fg
|
||||
if (!Number.isFinite(value)) return rule.open
|
||||
let open = rule.open
|
||||
for (const band of bands) {
|
||||
if (value < band.min) break
|
||||
fg = band.fg
|
||||
open = band.open
|
||||
}
|
||||
return fg
|
||||
return open
|
||||
}
|
||||
|
||||
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
|
||||
@@ -189,10 +245,17 @@ function hasLongLine(text: string): boolean {
|
||||
/** Millisecond clock for the optional stats sink; `performance` when present. */
|
||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
|
||||
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink): string {
|
||||
if (text.length === 0) return ''
|
||||
if (hasLongLine(text)) return text
|
||||
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink, spent?: { n: number }): string {
|
||||
if (text.length === 0) {
|
||||
if (spent !== undefined) spent.n = 0
|
||||
return ''
|
||||
}
|
||||
if (hasLongLine(text)) {
|
||||
if (spent !== undefined) spent.n = 0
|
||||
return text
|
||||
}
|
||||
const claimed: Span[] = []
|
||||
let totalMade = 0
|
||||
for (let ri = 0; ri < rules.length; ri++) {
|
||||
const rule = rules[ri]
|
||||
if (budgets[ri] <= 0) continue
|
||||
@@ -208,9 +271,9 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
|
||||
rule.regex.lastIndex++
|
||||
continue
|
||||
}
|
||||
if (!overlaps(claimed, start, end)) {
|
||||
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
|
||||
if (claim(claimed, start, end, rule, m[0])) {
|
||||
made++
|
||||
totalMade++
|
||||
budgets[ri]--
|
||||
if (budgets[ri] <= 0) break
|
||||
}
|
||||
@@ -223,13 +286,14 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
|
||||
stats.set(rule.id, stat)
|
||||
}
|
||||
}
|
||||
if (spent !== undefined) spent.n = totalMade
|
||||
if (claimed.length === 0) return text
|
||||
claimed.sort((a, b) => a.start - b.start)
|
||||
// `claim` keeps the spans sorted by start — no final sort needed here.
|
||||
let out = ''
|
||||
let pos = 0
|
||||
for (const c of claimed) {
|
||||
if (c.start > pos) out += text.slice(pos, c.start)
|
||||
out += wrap(text.slice(c.start, c.end), c.fg, c.bg)
|
||||
out += wrap(text.slice(c.start, c.end), c.open)
|
||||
pos = c.end
|
||||
}
|
||||
if (pos < text.length) out += text.slice(pos)
|
||||
@@ -249,6 +313,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
|
||||
let budgets = rules.map(() => MAX_PER_RULE)
|
||||
let usedBudget = false
|
||||
let out = ''
|
||||
// applyRun reports how many matches it claimed, replacing the old two
|
||||
// budgets.reduce() passes per text token.
|
||||
const spent = { n: 0 }
|
||||
for (const token of tokenize(chunk)) {
|
||||
if (token.kind === 'seq') {
|
||||
out += token.value
|
||||
@@ -260,10 +327,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
|
||||
out += token.value
|
||||
continue
|
||||
}
|
||||
const before = budgets.reduce((a, b) => a + b, 0)
|
||||
out += applyRun(token.value, rules, budgets, stats)
|
||||
const after = budgets.reduce((a, b) => a + b, 0)
|
||||
if (after < before) usedBudget = true
|
||||
spent.n = 0
|
||||
out += applyRun(token.value, rules, budgets, stats, spent)
|
||||
if (spent.n > 0) usedBudget = true
|
||||
}
|
||||
return usedBudget ? out : chunk
|
||||
}
|
||||
@@ -308,15 +374,30 @@ export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[]
|
||||
return spans
|
||||
}
|
||||
|
||||
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
|
||||
/**
|
||||
* True when `seg` is a dangling, unfinished escape fragment (must not be
|
||||
* emitted before its terminator arrives).
|
||||
*/
|
||||
function isIncompleteEscape(seg: string): boolean {
|
||||
// `seg` is expected to start at (or be) a lone ESC.
|
||||
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
|
||||
// A lone ESC — the leading half of any CSI/OSC/DCS/two-char/ST/Fe sequence.
|
||||
if (seg === '\x1b') return true
|
||||
// Unfinished CSI: ESC [ plus parameter bytes but no final byte in 0x40..0x7E.
|
||||
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true
|
||||
// Unfinished OSC: ESC ] ... with no BEL (\x07) or ST (\x1b\ ) terminator.
|
||||
// (A trailing half-ST is covered by the lone-ESC case above.)
|
||||
if (seg.startsWith('\x1b]') && !seg.includes('\x07') && !seg.includes('\x1b\\')) return true
|
||||
// Unfinished OSC / DCS / APC / PM / SOS: opened but no BEL (\x07) or ST
|
||||
// (\x1b\) terminator yet, so the fragment could still grow into any of them.
|
||||
// (A trailing half-ST — the ESC of `\x1b\` with the `\` still to come — is
|
||||
// covered by the lone-ESC case above, since findIncompleteIndex slices from
|
||||
// the *last* ESC.)
|
||||
// NB: `[]` would be an *empty* class in JS regex, so `]` is kept in a
|
||||
// startsWith instead of a merged character class.
|
||||
if (
|
||||
(seg.startsWith('\x1b]') || /^\x1b[PX^_]/.test(seg)) &&
|
||||
!seg.includes('\x07') &&
|
||||
!seg.includes('\x1b\\')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -356,6 +437,8 @@ export class HighlightStream {
|
||||
private rules: CompiledRule[]
|
||||
private stats?: StatsSink
|
||||
private buffer = ''
|
||||
/** Whether `buffer` holds any ESC — lets push skip the O(buffer) escape scans on escape-free floods. */
|
||||
private bufferHasEsc = false
|
||||
|
||||
constructor(rules: CompiledRule[]) {
|
||||
this.rules = rules
|
||||
@@ -378,6 +461,20 @@ export class HighlightStream {
|
||||
|
||||
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
|
||||
push(chunk: string): string {
|
||||
// Fast path: neither the held text nor the new chunk contains a single ESC,
|
||||
// so findIncompleteIndex/lastEscapeEnd would both come back -1. Skip their
|
||||
// O(buffer) rescans — they made escape-free floods (cat of a large text
|
||||
// file) quadratic in the held buffer — and go straight to the trailing-text
|
||||
// hold. Decision-identical to the full path below.
|
||||
if (!this.bufferHasEsc && !chunk.includes('\x1b')) {
|
||||
this.buffer += chunk
|
||||
if (this.buffer.length > MAX_CHUNK) {
|
||||
const raw = this.buffer
|
||||
this.buffer = ''
|
||||
return applyHighlights(raw, this.rules, this.stats)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
this.buffer += chunk
|
||||
|
||||
// (a) trailing incomplete escape -> hold it, emit the closed prefix.
|
||||
@@ -389,9 +486,11 @@ export class HighlightStream {
|
||||
// Give up waiting on a pathologically long unterminated sequence: emit
|
||||
// the fragment raw (applyHighlights skips raw \x1b in text runs).
|
||||
this.buffer = ''
|
||||
this.bufferHasEsc = false
|
||||
return applyHighlights(head, this.rules, this.stats) + tail
|
||||
}
|
||||
this.buffer = tail
|
||||
this.bufferHasEsc = true // the held tail starts at the dangling ESC
|
||||
return applyHighlights(head, this.rules, this.stats)
|
||||
}
|
||||
|
||||
@@ -404,12 +503,16 @@ export class HighlightStream {
|
||||
if (this.buffer.length > MAX_CHUNK) {
|
||||
const raw = this.buffer
|
||||
this.buffer = ''
|
||||
this.bufferHasEsc = false
|
||||
return applyHighlights(raw, this.rules, this.stats)
|
||||
}
|
||||
// The buffer (or the chunk that just joined it) brought the ESC in.
|
||||
this.bufferHasEsc = true
|
||||
return ''
|
||||
}
|
||||
const head = this.buffer.slice(0, lastEnd)
|
||||
this.buffer = this.buffer.slice(lastEnd)
|
||||
this.bufferHasEsc = this.buffer.includes('\x1b')
|
||||
return applyHighlights(head, this.rules, this.stats)
|
||||
}
|
||||
|
||||
@@ -417,6 +520,7 @@ export class HighlightStream {
|
||||
flush(): string {
|
||||
const b = this.buffer
|
||||
this.buffer = ''
|
||||
this.bufferHasEsc = false
|
||||
return applyHighlights(b, this.rules, this.stats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,9 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importMode': 'Mode',
|
||||
'settings.highlight.importReplace': 'Replace current rules',
|
||||
'settings.highlight.importAppend': 'Append to current rules',
|
||||
'settings.highlight.importReplaceTitle': 'Replace current rules?',
|
||||
'settings.highlight.importReplaceDesc':
|
||||
'Importing overwrites every current rule with this file. This cannot be undone.',
|
||||
'settings.highlight.importOk': 'Import',
|
||||
'settings.highlight.importEmpty': 'No usable rules found',
|
||||
'settings.highlight.importBadJson': 'Not valid JSON',
|
||||
@@ -214,6 +217,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.modeOff': 'Off',
|
||||
'settings.highlight.modeDesc':
|
||||
'Basic only: keeps just the key rules (errors, success, warnings, dangerous commands, secrets) — cheaper and quieter. Off: no colour injection at all (rules and colours are kept).',
|
||||
'settings.highlight.basic': 'Basic rule',
|
||||
'settings.highlight.basicHint': 'keep this rule active when the highlight mode is "Basic only"',
|
||||
'settings.highlight.category': 'Category',
|
||||
'settings.highlight.groupByCategory': 'Group by category',
|
||||
'settings.highlight.category.safety': 'Safety',
|
||||
@@ -245,6 +250,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
|
||||
'settings.highlight.profileRules': 'Included rules',
|
||||
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
|
||||
'settings.highlight.profileExcluded': 'Excluded {n}: {list}',
|
||||
'settings.highlight.profileEmpty': 'No rule sets yet',
|
||||
'settings.highlight.profileDeleteTitle': 'Delete rule set',
|
||||
'settings.highlight.actions': 'Actions',
|
||||
|
||||
@@ -196,6 +196,9 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importMode': '取り込み方法',
|
||||
'settings.highlight.importReplace': '現在のルールを置き換える',
|
||||
'settings.highlight.importAppend': '現在のルールに追加する',
|
||||
'settings.highlight.importReplaceTitle': '現在のルールを置き換えますか?',
|
||||
'settings.highlight.importReplaceDesc':
|
||||
'インポートすると現在のすべてのルールがこのファイルの内容で上書きされます。元に戻せません。',
|
||||
'settings.highlight.importOk': 'インポート',
|
||||
'settings.highlight.importEmpty': '有効なルールが見つかりません',
|
||||
'settings.highlight.importBadJson': '有効な JSON ではありません',
|
||||
@@ -210,6 +213,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.modeOff': 'オフ',
|
||||
'settings.highlight.modeDesc':
|
||||
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
|
||||
'settings.highlight.basic': '基本ルール',
|
||||
'settings.highlight.basicHint': 'オンにすると、ハイライトが「基本のみ」モードのときもこのルールが適用されます',
|
||||
'settings.highlight.category': '分類',
|
||||
'settings.highlight.groupByCategory': '分類でグループ化',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
@@ -240,6 +245,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
|
||||
'settings.highlight.profileRules': '含めるルール',
|
||||
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
|
||||
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
|
||||
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
|
||||
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
|
||||
'settings.highlight.actions': '操作',
|
||||
|
||||
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importMode': '导入方式',
|
||||
'settings.highlight.importReplace': '替换现有规则',
|
||||
'settings.highlight.importAppend': '追加到现有规则',
|
||||
'settings.highlight.importReplaceTitle': '替换现有规则?',
|
||||
'settings.highlight.importReplaceDesc': '导入后当前全部规则将被这份文件覆盖,无法恢复。',
|
||||
'settings.highlight.importOk': '导入',
|
||||
'settings.highlight.importEmpty': '没有解析到可用规则',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.modeBasic': '仅基础',
|
||||
'settings.highlight.modeOff': '关闭',
|
||||
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
|
||||
'settings.highlight.basic': '基础规则',
|
||||
'settings.highlight.basicHint': '开启后,终端高亮为「仅基础」模式时该规则仍会生效',
|
||||
'settings.highlight.category': '分类',
|
||||
'settings.highlight.groupByCategory': '按分类分组',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
|
||||
'settings.highlight.profileRules': '包含的规则',
|
||||
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
|
||||
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
|
||||
'settings.highlight.profileEmpty': '尚未创建规则集',
|
||||
'settings.highlight.profileDeleteTitle': '删除规则集',
|
||||
'settings.highlight.actions': '操作',
|
||||
|
||||
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importMode': '匯入方式',
|
||||
'settings.highlight.importReplace': '取代現有規則',
|
||||
'settings.highlight.importAppend': '附加到現有規則',
|
||||
'settings.highlight.importReplaceTitle': '取代現有規則?',
|
||||
'settings.highlight.importReplaceDesc': '匯入後目前全部規則將被這份檔案覆蓋,無法復原。',
|
||||
'settings.highlight.importOk': '匯入',
|
||||
'settings.highlight.importEmpty': '沒有解析到可用規則',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.modeBasic': '僅基礎',
|
||||
'settings.highlight.modeOff': '關閉',
|
||||
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
|
||||
'settings.highlight.basic': '基礎規則',
|
||||
'settings.highlight.basicHint': '開啟後,終端機高亮為「僅基礎」模式時該規則仍會生效',
|
||||
'settings.highlight.category': '分類',
|
||||
'settings.highlight.groupByCategory': '依分類分組',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
|
||||
'settings.highlight.profileRules': '包含的規則',
|
||||
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
|
||||
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
|
||||
'settings.highlight.profileEmpty': '尚未建立規則集',
|
||||
'settings.highlight.profileDeleteTitle': '刪除規則集',
|
||||
'settings.highlight.actions': '操作',
|
||||
|
||||
@@ -28,6 +28,8 @@ const BUNDLES = [
|
||||
// Pulls in settingsStore + broadcast, which is why the electron stub needs
|
||||
// powerMonitor as well.
|
||||
{ entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' },
|
||||
// Lock keyboard classifier: pure, so the bundle needs no electron surface.
|
||||
{ entry: 'src/main/lockShortcuts.ts', out: 'tests/.lock-shortcuts.cjs' },
|
||||
// zmodem.js stays bundled (NOT external) — the test drives a second in-process
|
||||
// Sentry from the same library.
|
||||
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
|
||||
|
||||
+48
-1
@@ -12,7 +12,7 @@
|
||||
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
|
||||
* node tests/hl-rules.mjs
|
||||
*/
|
||||
import { compileRules, applyHighlights } from '../src/renderer/src/terminal/highlightEngine.ts'
|
||||
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
|
||||
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
|
||||
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
|
||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
|
||||
@@ -739,5 +739,52 @@ console.log('[profiles]')
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 19. escape sequences pass through untouched ---------------------------
|
||||
console.log('[escape sequences]')
|
||||
{
|
||||
// DCS / APC / PM / SOS bodies and single-char Fe escapes must keep their
|
||||
// bytes raw — never wrapped in SGR — while text around them stays highlighted.
|
||||
const families = [
|
||||
['DCS (ST)', '\x1bP0;1;0q#0;2;0;0;0~..\x1b\\'],
|
||||
['DCS (BEL)', '\x1bP1;2|9/9\x07'],
|
||||
['APC (kitty graphics)', '\x1b_Gf=32,s=10,v=10,m=1\x1b\\'],
|
||||
['PM', '\x1b^weather\x1b\\'],
|
||||
['SOS', '\x1bXsos-frame\x1b\\']
|
||||
]
|
||||
// one coloured span, exactly as the engine emits it (ESC-free span text)
|
||||
const SPAN_RE = /\x1b\[38;2;\d+;\d+;\d+(?:;48;2;\d+;\d+;\d+)?m[^\x1b]*\x1b\[0m/g
|
||||
for (const [name, seq] of families) {
|
||||
const out = applyHighlights(`DONE ${seq} ERROR`, rules)
|
||||
ok(out.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), `${name}: text before it keeps its highlight`)
|
||||
ok(out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m`), `${name}: text after it keeps its highlight`)
|
||||
ok(
|
||||
out.replace(SPAN_RE, '').includes(seq),
|
||||
`${name}: sequence bytes pass through raw, never wrapped in SGR`
|
||||
)
|
||||
}
|
||||
|
||||
const fe = applyHighlights('\x1b7DONE\x1b8 1', rules)
|
||||
ok(fe.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), 'text between DECSC/DECRC keeps its highlight')
|
||||
ok(fe.includes('\x1b7') && fe.includes('\x1b8'), 'DECSC/DECRC bytes pass through byte-for-byte')
|
||||
|
||||
// backstop: a text run holding a residual (unterminated) ESC passes through
|
||||
// raw byte-for-byte — never partially highlighted, never corrupted
|
||||
const raw = 'DONE \x1bP0;1;0qcut ERROR'
|
||||
ok(applyHighlights(raw, rules) === raw, 'an unterminated DCS keeps its run byte-for-byte raw')
|
||||
|
||||
// the stream treats a cut DCS/APC/PM/SOS tail as incomplete, like OSC
|
||||
ok(hooks.isIncompleteEscape('\x1b') === true, 'a lone trailing ESC stays incomplete')
|
||||
ok(hooks.isIncompleteEscape('\x1bP0;1;0qcut') === true, 'a cut DCS tail counts as incomplete')
|
||||
ok(hooks.isIncompleteEscape('\x1b_Gf=32') === true, 'a cut APC tail counts as incomplete')
|
||||
ok(
|
||||
hooks.isIncompleteEscape('\x1b^w') === true && hooks.isIncompleteEscape('\x1bXs') === true,
|
||||
'cut PM/SOS tails count as incomplete'
|
||||
)
|
||||
ok(hooks.isIncompleteEscape('\x1bP0;1;0qx\x1b\\') === false, 'a terminated DCS is complete')
|
||||
ok(hooks.isIncompleteEscape('\x1b7') === false, 'a complete two-byte Fe escape is complete')
|
||||
ok(hooks.findIncompleteIndex('DONE \x1bP0;1;0qcut') === 5, 'the stream holds from the DCS initiator')
|
||||
ok(hooks.findIncompleteIndex('DONE \x1b7') === -1, 'a trailing DECSC is not held as incomplete')
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -7,12 +7,24 @@ import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
|
||||
|
||||
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
|
||||
|
||||
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints
|
||||
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints.
|
||||
// The tail line exercises the string-terminated families (DCS/sixel, APC/kitty
|
||||
// graphics, PM, SOS), a BEL-terminated DCS, and DECSC/DECRC (`\x1b7`/`\x1b8`)
|
||||
// so every cut point through them is checked against the reference.
|
||||
const banner =
|
||||
'\x1b]0;kimi — session\x07' +
|
||||
'\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' +
|
||||
'\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' +
|
||||
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n'
|
||||
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n' +
|
||||
'\x1b7' +
|
||||
'\x1b[38;2;224;224;224mERROR 85% exit code 1\x1b[0m' +
|
||||
'\x1bP0;1;0q#0;2;0;0;0~-..\x1b\\' +
|
||||
'\x1bP1;2|9/9\x07' +
|
||||
'\x1b_Gf=32,s=10,v=10,m=1\x1b\\' +
|
||||
'\x1b^pm-note\x1b\\' +
|
||||
'\x1bXsos-frame\x1b\\' +
|
||||
'\x1b[38;2;136;136;136mdone \u2713\x1b[0m' +
|
||||
'\x1b8\r\n'
|
||||
|
||||
const reference = applyHighlights(banner, rules)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Lock-shortcut classifier self-test (lock-shortcuts.mjs).
|
||||
*
|
||||
* Table-driven coverage of the two pure predicates the before-input-event guard
|
||||
* in src/main/index.ts is built from. This is the decision v1.0.17 got wrong
|
||||
* from inside an untestable closure, so every chord spelling that matters is
|
||||
* pinned here:
|
||||
* - the panic lock is exactly Ctrl+L: Shift (IME switch on zh/ja) and Alt
|
||||
* (AltGr on European layouts) must pass through, as must Caps-Lock 'L'
|
||||
* variants only when they really are Ctrl+L
|
||||
* - the locked-window blocklist: reload (F5, Ctrl+R, Ctrl+Shift+R), zoom in
|
||||
* both plain and Shift-shifted spellings ('='/'+' and '-'/'_'), reset ('0'),
|
||||
* and DevTools (Ctrl+Shift+I/J/C)
|
||||
* - what must NOT be blocked: plain typing, Ctrl+C copy, Ctrl+L itself while
|
||||
* already locked (it falls to the page), and unrelated Ctrl chords
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/lock-shortcuts.mjs (must exit 0)
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { isLockBlockedShortcut, isPanicLockChord } = require('./.lock-shortcuts.cjs')
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
/** Build a classifier input; modifiers default to off. */
|
||||
const key = (k, mods = {}) => ({
|
||||
key: k,
|
||||
control: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
...mods
|
||||
})
|
||||
const ctrl = (k, mods = {}) => key(k, { control: true, ...mods })
|
||||
|
||||
// ---- panic lock chord (exactly Ctrl+L) --------------------------------------
|
||||
console.log('panic lock chord')
|
||||
ok(isPanicLockChord(ctrl('l')), 'Ctrl+L is the panic chord')
|
||||
ok(isPanicLockChord(ctrl('L')), 'Ctrl+L with Caps Lock still matches (key is case-folded)')
|
||||
ok(!isPanicLockChord(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes (IME input-mode switch)')
|
||||
ok(!isPanicLockChord(ctrl('l', { alt: true })), 'Ctrl+Alt+L passes (AltGr on European layouts)')
|
||||
ok(!isPanicLockChord(ctrl('l', { meta: true })), 'Ctrl+Meta+L passes')
|
||||
ok(!isPanicLockChord(key('l')), 'plain L passes (would eat typing otherwise)')
|
||||
ok(!isPanicLockChord(ctrl('r')), 'Ctrl+R is not the panic chord')
|
||||
ok(!isPanicLockChord(ctrl('l', { shift: true, alt: true })), 'Ctrl+Shift+Alt+L passes')
|
||||
|
||||
// ---- locked-window blocklist -------------------------------------------------
|
||||
console.log('locked-window blocklist')
|
||||
ok(isLockBlockedShortcut(key('f5')), 'F5 blocked (reload)')
|
||||
ok(isLockBlockedShortcut(key('F5')), 'F5 case-folded')
|
||||
ok(isLockBlockedShortcut(ctrl('r')), 'Ctrl+R blocked (reload)')
|
||||
ok(isLockBlockedShortcut(ctrl('r', { shift: true })), 'Ctrl+Shift+R blocked (force reload)')
|
||||
ok(isLockBlockedShortcut(ctrl('=')), 'Ctrl+= blocked (zoom in)')
|
||||
ok(isLockBlockedShortcut(ctrl('+')), "Ctrl+Shift+= arrives as '+' and is blocked")
|
||||
ok(isLockBlockedShortcut(ctrl('-')), 'Ctrl+- blocked (zoom out)')
|
||||
ok(isLockBlockedShortcut(ctrl('_')), "Ctrl+Shift+- arrives as '_' and is blocked")
|
||||
ok(isLockBlockedShortcut(ctrl('0')), 'Ctrl+0 blocked (zoom reset)')
|
||||
ok(isLockBlockedShortcut(ctrl('i', { shift: true })), 'Ctrl+Shift+I blocked (DevTools)')
|
||||
ok(isLockBlockedShortcut(ctrl('j', { shift: true })), 'Ctrl+Shift+J blocked (DevTools console)')
|
||||
ok(isLockBlockedShortcut(ctrl('c', { shift: true })), 'Ctrl+Shift+C blocked (DevTools inspect)')
|
||||
|
||||
// ---- must not be blocked -----------------------------------------------------
|
||||
console.log('pass-through chords')
|
||||
ok(!isLockBlockedShortcut(key('r')), 'plain typing passes')
|
||||
ok(!isLockBlockedShortcut(ctrl('c')), 'Ctrl+C passes (terminal copy)')
|
||||
ok(!isLockBlockedShortcut(ctrl('l')), 'Ctrl+L passes the blocklist (handled by the panic branch)')
|
||||
ok(!isLockBlockedShortcut(ctrl('i')), 'Ctrl+I without Shift passes')
|
||||
ok(!isLockBlockedShortcut(key('f12')), 'F12 passes (Electron binds no default for it)')
|
||||
ok(!isLockBlockedShortcut(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes the blocklist too')
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[lock-shortcuts] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[lock-shortcuts] ALL CHECKS PASSED')
|
||||
Reference in new issue
Block a user