Compare commits

..
9 Commits
Author SHA1 Message Date
954801926@qq.com d1376b7d1d chore: release v1.0.20
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-01 21:56:24 +08:00
954801926@qq.com d04452cd7f fix(ime): pin @xterm/xterm 6.1.0-beta.304 for IME caret follow; sync lockfile root version in predist
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-01 21:28:07 +08:00
Bill 86f51df2e6 fix(lock): harden lock screen input paths
CI / typecheck + test + build (windows) (push) Canceled after 0s
- remove the application menu while locked (lockMenu.ts): Alt reveals the
  default menu and its mouse-clickable Reload/DevTools/Zoom items bypass
  before-input-event entirely; menu is rebuilt from the default template on
  unlock, startup-restored locks covered from initLockController
- extract the keyboard classification into pure lockShortcuts.ts
  (isLockBlockedShortcut/isPanicLockChord) with a table-driven test
  (lock-shortcuts.mjs, 29 cases) — the v1.0.17 lockout escaped CI because
  this decision lived inline in createWindow()
- reserve Ctrl+L in the global show/hide shortcut recorder: a global
  registration intercepts the chord at OS level and silently disables the
  panic lock
- skip auto-repeat keydowns in the panic-lock branch (held Ctrl+L on an
  unconfigured app re-read lock.json + settings.json per repeat)
- LockScreen: re-sync the cooldown clock on visibilitychange/focus/pageshow
  so a suspended renderer timer cannot leave the password input disabled
  past the real deadline
2026-09-30 00:38:57 +08:00
Bill dd08f8054a chore: release v1.0.19
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-09-28 13:39:27 +08:00
Bill dba2b14c33 feat(updater): GitHub-first update checks with 20s connectivity probe
checkWithFallback now probes api.github.com through a dedicated system-proxy
session with a 20s AbortSignal timeout before choosing the feed: reachable ->
GitHub releases (with Gitea as the error fallback), unreachable/timeout ->
straight to the domestic Gitea channel (forced direct), so proxy-less users
never hang on a dead proxy. Feed is decided before touching the updater, so
there is never a raced concurrent checkForUpdates. Docs updated: AGENTS.md
update-mechanism section and the release flow (GitHub assets ship per version
again, release.cjs runs with no skip flags).
2026-09-28 13:39:26 +08:00
Bill 680254cad6 fix(release): drop stray TS annotation in .cjs GitHub path
The hardening-round edit left `(): Promise<Response> =>` in a plain .cjs
file; every run since that commit died at parse time before reaching any flag
handling.
2026-09-28 13:39:25 +08:00
Bill b7c7c5cd76 fix(highlight): settings UI fixes from review — grouped view, import guard, basic flag
CI / typecheck + test + build (windows) (push) Canceled after 0s
- grouped view actually clusters: drop the priority column's defaultSortOrder
  that made antd re-sort the dataSource and undo the category clustering
- replace import is Popconfirm-guarded and the import mode resets to append
  each time the dialog opens (it stayed on the destructive choice)
- rule editor exposes the basic flag (basic-mode membership) with a switch;
  clearing it on edit now actually removes the flag
- rule/profile writes read the store at call time instead of the render-scoped
  array, so two writes in one React batch no longer drop the first
- profile editor lists the rules a non-empty profile leaves out (uses the
  previously dead excludedByProfile helper)
- bands editor keeps rows sorted by min; band preview keys by index (duplicate
  min no longer collides); clear-background also closes the bg picker
- TerminalView pushes compiled rules into the HighlightStream from an effect
  instead of during render
2026-09-28 13:06:40 +08:00
Bill 462da60977 perf(highlight): precompute SGR at compile, sorted span claiming, stream ESC-free fast path
- compileRules precomputes the full SGR open sequence per rule and per band;
  wrap() is now pure concatenation, bandOpen() a table lookup (output bytes
  unchanged, bg keeps its unvalidated white-fallback)
- claim() replaces the linear overlaps() scan: claimed spans stay sorted by
  start, O(1) append on the common left-to-right sweep plus one binary search
  otherwise (match-dense 200KB payload: -19% one-shot, -56% streamed)
- HighlightStream: bufferHasEsc flag skips the O(buffer) escape rescans when
  neither the held text nor the chunk contains ESC, fixing quadratic rescan on
  escape-free floods (plain 200KB streamed: -54%)
- applyHighlights tracks the consumed match budget incrementally instead of
  two budgets.reduce() passes per text token
- ESCAPE_RE now covers DCS/APC/PM/SOS (BEL or ST terminated) and single-char
  Fe escapes (DECSC/DECRC/NEL/RI/RIS, orphan ST); isIncompleteEscape holds cut
  tails of the new families; split-smoke exercises every cut point through them
2026-09-28 13:06:29 +08:00
Bill 4e5377f49c fix: hardening round from code review (4 P1 + 15 P2)
CI / typecheck + test + build (windows) (push) Canceled after 0s
P1:
- settingsStore: back up an unparseable settings.json to .bak before
  falling back to defaults, so the next mutation can no longer silently
  wipe custom themes/highlight rules
- ptyDispatcher: fan out per-session data/exit handlers (Set instead of
  a single slot) so SSH split panes stop stealing each other's stream
- FilePanel: monotonic refresh token keeps stale listings from painting
  over a newer navigation; upload finish no longer yanks the panel back
- settings.css: active settings-tab label derives from --chrome-fg so it
  stays visible on the shipped light themes

P2 (main/renderer):
- paste guard: a paste ending in a newline always confirms
- Workspace: closing an SSH pane no longer seeds the local cwd with a
  remote path
- tray: skip close-dialog continuation on a destroyed window
- commands: close zombie 'in-progress' session logs at hydrate
- zmodem: clear the stale offer timer before arming a new one
- connectionsStore: coerce/validate renderer input before persisting
- sftp: OperationError marker class keeps translated errors out of the
  transport-retry classifier
- CommandsPanel: surface save failures inside the dialog
- ConnectionSidebar: drop a tautological tooltip condition

P2 (i18n/tooling/tests):
- localize the 16 ANSI color labels and the highlight sample text
  (21 new keys across zh-CN/zh-TW/en/ja)
- sync-changelog: keep ### subheadings, normalize CRLF notes
- release.cjs: GitHub release reuse-by-tag (idempotent re-runs); fail
  loudly on a failed Gitea asset listing
- commands-store test: absent historyEnabled now truly tests absence
2026-09-27 22:25:03 +08:00
50 changed files with 1120 additions and 244 deletions

No files matched your search

+4
View File
@@ -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/
+12 -12
View File
@@ -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`
+28
View File
@@ -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.
+28
View File
@@ -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 ではロック中、レンダラー側ですべてのキーボード入力を握りつぶしていました(ズーム/再読み込みのショートカットを無効化する意図でした)。しかしキー入力の既定動作は「フォーカスされたフィールドへの文字挿入」であり、その結果、ロック画面のパスワード欄に何も入力できず、ロック解除もできなくなっていました。
+28
View File
@@ -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 在锁屏期间由渲染层拦截了全部键盘输入(本意是挡住缩放/刷新快捷键),但按键的默认行为正是「向聚焦的输入框插入字符」,导致锁屏界面密码框收不到任何输入、无法解锁。
+28
View File
@@ -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
View File
@@ -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 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
+6 -6
View File
@@ -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
View File
@@ -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",
+41 -15
View File
@@ -124,7 +124,12 @@ async function gitea() {
}
}
const listed = await fetch(`${base}/releases/${rel.id}/assets`, { headers: auth })
const attached = new Set(listed.ok ? (await listed.json()).map((a) => a.name) : [])
// A failed listing must not read as "nothing attached": that would re-POST
// every already-uploaded installer and abort the run on the duplicate-name
// rejection, before the channel update could run. Fail loudly instead — a
// re-run then resumes correctly.
if (!listed.ok) throw new Error(`Gitea asset listing failed: HTTP ${listed.status}`)
const attached = new Set((await listed.json()).map((a) => a.name))
for (const f of files) {
const name = path.basename(f)
if (attached.has(name)) { console.log(` asset ${name}: already attached, skipped`); continue }
@@ -237,20 +242,41 @@ async function giteaChannel() {
async function github() {
console.log('=== GitHub release ===')
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
},
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
...(proxyAgent ? { dispatcher: proxyAgent } : {})
})
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json()
console.log('release created, id =', rel.id)
const api = 'https://api.github.com/repos/billowliu2/OpenTerminal'
const ghHeaders = {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
}
const gh = (url, init) =>
fetch(url, { ...init, headers: ghHeaders, ...(proxyAgent ? { dispatcher: proxyAgent } : {}) })
// 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 = () => gh(`${api}/releases/tags/v${V}`)
let resp = await byTag()
let rel
if (resp.ok) {
rel = await resp.json()
console.log('release already exists, reusing id =', rel.id)
} else {
resp = await gh(`${api}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (resp.ok) {
rel = await resp.json()
console.log('release created, id =', rel.id)
} else {
const detail = (await resp.text()).slice(0, 300)
// 422 already_exists = the tag carries a release from an earlier partial run.
const again = resp.status === 422 ? await byTag() : undefined
if (!again || !again.ok) throw new Error(`GitHub release create failed: ${resp.status} ${detail}`)
rel = await again.json()
console.log('release already exists, reusing id =', rel.id)
}
}
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
+9 -4
View File
@@ -33,10 +33,15 @@ const sync = ({ notes, changelog, header, required }) => {
const body = fs
.readFileSync(notesPath, 'utf8')
// Drop the notes' own title / `## vX` heading: the section heading is built
// here so every changelog stays uniform (`## vX.Y.Z - date`).
.replace(/^#.*\n/gm, '')
.replace(new RegExp(`^## v${pkgVersion}.*\\n`), '')
// Normalize newlines first: a CRLF notes file would otherwise keep its `\r`
// in every line, and the join below re-adds the changelog's ending on top —
// every line of the merged section rendering as `\r\r\n` (doubled blanks).
.replace(/\r\n/g, '\n')
// Drop only the notes' own title and the `## v<pkgVersion>` heading: the
// section heading is built here so every changelog stays uniform
// (`## vX.Y.Z - date`). Stripping every `#`-line (the old behaviour) also
// removed `###` subheadings, merging 新功能/修复 groups into one flat list.
.replace(new RegExp(`^# .*\\n|^## v${pkgVersion}.*\\n`, 'gm'), '')
.replace(/^\s+/, '')
.trimEnd()
+11 -1
View File
@@ -229,6 +229,7 @@ export class CommandsStore {
if (!existsSync(this.indexFile)) return
const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8'))
if (!Array.isArray(raw)) return
let closed = false
for (const x of raw) {
if (
x &&
@@ -239,10 +240,19 @@ export class CommandsStore {
// index.json is data, not trust: a tampered or hand-edited `file`
// must never turn logWrite into an arbitrary-path append.
if (!this.isLoggableFile(meta.file)) continue
// A log still "in progress" here was interrupted by a crash or a
// force-kill: its session id belonged to the previous run, so no
// logStop will ever match it and its sanitizer is gone (logWrite
// would be a permanent no-op). Close it instead of reviving it as
// an active entry nothing can ever end.
if (meta.endedAt === undefined) {
meta.endedAt = Date.now()
closed = true
}
this.metasByFile.set(meta.file, meta)
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
}
}
if (closed) this.persistIndex()
} catch {
// index missing / corrupt -> rebuild on next write
}
+37 -1
View File
@@ -20,6 +20,41 @@ import { writeJson } from './store'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
const AUTH_METHODS: ReadonlySet<string> = new Set(['password', 'privateKey', 'agent'])
/**
* Runtime coercion for renderer-supplied connection fields. saveConnection
* copies input values verbatim, and they flow straight into ssh2's
* ConnectConfig (port, keepalive) — a malformed save or a renderer regression
* must not persist `port: "22"` or a NaN-bound keepalive. Repair over drop,
* like every other store: strings stay strings, numbers are range-checked,
* unknown auth falls back to password, optional fields collapse to undefined.
*/
function coerceConnectionInput(input: SshConnectionInput): SshConnectionInput {
const out = { ...input } as Record<string, unknown>
out.name = typeof input.name === 'string' ? input.name : ''
out.group = typeof input.group === 'string' && input.group !== '' ? input.group : undefined
out.host = typeof input.host === 'string' ? input.host.trim() : ''
out.username = typeof input.username === 'string' ? input.username : ''
const port = Number(input.port)
out.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22
out.auth = AUTH_METHODS.has(input.auth as string) ? input.auth : 'password'
out.askPasswordAtConnect = input.askPasswordAtConnect === true
out.askPassphraseAtConnect = input.askPassphraseAtConnect === true
out.keyPath =
typeof input.keyPath === 'string' && input.keyPath !== '' ? input.keyPath : undefined
const keepalive = Number(input.keepaliveIntervalSec)
out.keepaliveIntervalSec =
Number.isFinite(keepalive) && keepalive >= 0 ? Math.round(keepalive) : 0
out.highlightProfileId =
typeof input.highlightProfileId === 'string' && input.highlightProfileId !== ''
? input.highlightProfileId
: undefined
for (const key of SECRET_KEYS) {
if (out[key] !== undefined && typeof out[key] !== 'string') out[key] = undefined
}
return out as unknown as SshConnectionInput
}
/** Internal persisted record: public fields + encrypted secret fields. */
interface StoredConnection {
@@ -150,7 +185,8 @@ export class ConnectionsStore {
return stored ? decrypt(stored[encField] as string | undefined) : undefined
}
saveConnection(input: SshConnectionInput): SshConnection {
saveConnection(rawInput: SshConnectionInput): SshConnection {
const input = coerceConnectionInput(rawInput)
const list = this.load()
let stored: StoredConnection | undefined
+11 -37
View File
@@ -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
+9 -1
View File
@@ -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 =
+36
View File
@@ -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))
}
+57
View File
@@ -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'
)
}
+25 -2
View File
@@ -1,5 +1,5 @@
import { app, ipcMain, powerSaveBlocker } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
@@ -435,13 +435,36 @@ function reportWarnings(warnings: string[]): void {
}
}
/**
* A file that exists but cannot be parsed must never be silently destroyed:
* loadSettings falls back to defaults, and the next mutation (any settings save,
* even the tray persisting its close action) would rewrite settings.json from
* those defaults — taking the user's custom themes and highlight rules with it.
* Keep a one-time copy the user can recover from before that can happen.
*/
function backupUnparseableSettings(err: unknown): void {
try {
const file = settingsPath()
if (!existsSync(file)) return
const bak = `${file}.bak`
if (existsSync(bak)) return // keep the first backup; later loads must not clobber it
copyFileSync(file, bak)
reportWarnings([
`settings.json unparseable (${err instanceof Error ? err.message : String(err)}) — original kept at settings.json.bak`
])
} catch {
// best effort: the backup must never break settings loading
}
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings, errors } = deepMerge(raw)
reportWarnings(errors)
return settings
} catch {
} catch (err) {
backupUnparseableSettings(err)
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
+18 -3
View File
@@ -65,6 +65,14 @@ const sftpCache = new Map<string, SFTPWrapper>()
*/
class SessionGoneError extends Error {}
/**
* Our own "operation failed for a non-transport reason" error (server-side
* failure summary, user cancellation). isTransportError rejects these outright:
* their messages are translated and can embed remote paths/output, so text
* matching would make the retry decision locale- and filename-dependent.
*/
class OperationError extends Error {}
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const cached = sftpCache.get(sessionId)
if (cached) return cached
@@ -100,9 +108,16 @@ function evictSftp(sessionId: string): void {
* already exists) mean the CHANNEL IS ALIVE — retrying them would just open
* another subsystem channel, which strict sshd (MaxSessions 2) punishes by
* dropping the whole connection. Only transport-level death retries.
*
* Classification is class-based for our own errors (SessionGoneError /
* OperationError) and matches only the ssh2 library's own message strings for
* the rest: ssh2 is English-only and never translated, so the decision cannot
* drift with the UI locale (our translated messages arrive as OperationError
* and are rejected before any text is inspected).
*/
function isTransportError(err: unknown): boolean {
if (err instanceof SessionGoneError) return true
if (err instanceof OperationError) return false
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
}
@@ -211,7 +226,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
}
}
if (failures.length > 0) {
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
throw new OperationError(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
}
})
}
@@ -329,7 +344,7 @@ export function uploadRemote(
let pos = 0
let lastEmit = 0
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
if (firstError) throw firstError
while (inflight.size >= UPLOAD_WINDOW) {
await Promise.race(inflight)
@@ -420,7 +435,7 @@ export function downloadRemote(
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
sftp.read(handle, buf, 0, CHUNK, pos, cb)
)
+8
View File
@@ -87,6 +87,9 @@ export function initTray(showOrCreate: () => void): void {
}
function hideToTray(win: BrowserWindow): void {
// A quit started while the close dialog was pending destroys the window
// before its resolution runs; touching a destroyed window throws.
if (win.isDestroyed()) return
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
@@ -126,6 +129,11 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s
checkboxChecked: false,
noLink: true
})
// The dialog resolves when the window is destroyed underneath it (tray exit
// or OS shutdown called app.quit() while it was open): the quit is already
// underway, so neither branch may run — and must not persist a choice the
// user never made.
if (win.isDestroyed()) return
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
+53 -14
View File
@@ -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
+5
View File
@@ -342,6 +342,11 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
} catch {
// never crash the event loop
}
// Clear any previous offer timer first: a re-detect while an earlier
// offer is still pending overwrites engine.offerTimer, and the orphaned
// timer would fire at its original deadline and abort a legitimate
// pending offer ahead of its own timeout.
if (engine.offerTimer) clearTimeout(engine.offerTimer)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
+19 -8
View File
@@ -31,6 +31,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
const [name, setName] = useState('')
const [command, setCommand] = useState('')
const [note, setNote] = useState('')
/** Save failure surfaced inside the dialog instead of an unhandled rejection. */
const [saveError, setSaveError] = useState('')
/** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
@@ -68,20 +70,28 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
setName('')
setCommand('')
setNote('')
setSaveError('')
setAddOpen(true)
}, [])
const handleSave = useCallback(async (): Promise<void> => {
const trimmed = command.trim()
if (!trimmed) return
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
try {
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
} catch (err) {
// Keep the dialog open with the typed values; a silent no-op would look
// like the save button doing nothing.
setSaveError((err as Error)?.message || t('common.saveFailed'))
return
}
setAddOpen(false)
refresh()
}, [name, command, note, refresh])
@@ -182,6 +192,7 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
className="commands-modal"
>
<div className="commands-form">
{saveError && <div className="commands-save-error">{saveError}</div>}
<label className="commands-field">
<span className="commands-field-label">{t('panels.commands.name')}</span>
<input
+8
View File
@@ -138,6 +138,14 @@
gap: 12px;
}
/* IPC/store failure during save: shown inside the dialog (in the app's danger
colour) so a failed save is visible instead of a silent no-op. */
.commands-save-error {
color: #f85149;
font-size: 12px;
word-break: break-all;
}
.commands-field {
display: flex;
flex-direction: column;
@@ -289,7 +289,7 @@ function Row({
<div className="connections-row-main">
<div className="connections-row-name">
<span className="connections-row-label">{conn.name}</span>
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
<Tooltip title={t('ssh.sidebar.dblClickConnect', { host: conn.host })}>
<AuthTags auth={conn.auth} />
</Tooltip>
</div>
+17
View File
@@ -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>
+39 -12
View File
@@ -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>
@@ -233,7 +237,7 @@ export function HighlightTab(): React.JSX.Element {
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
{t('settings.highlight.sampleText')}
</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])
}
+5 -3
View File
@@ -45,10 +45,12 @@
}
/* antd paints the active tab label in the primary blue, which is hard to read
on the accent-tinted row; keep it identical to the inactive labels. The
extra .ant-tabs-tab class out-specifies antd's runtime-injected rule. */
on the accent-tinted row; keep it identical to the inactive labels. Theme-
derived (not #ffffff): on the shipped light themes a white label on the
near-white tinted row was invisible. The extra .ant-tabs-tab class
out-specifies antd's runtime-injected rule. */
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #ffffff;
color: var(--chrome-fg, #cccccc);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
+21 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowUpOutlined,
CopyOutlined,
@@ -332,19 +332,32 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
/** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected
/** Monotonic token: only the most recently issued listing may paint state. */
const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
const dirRef = useRef(dir)
const refresh = useCallback(
async (target: string): Promise<void> => {
const seq = ++refreshSeqRef.current
setLoading(true)
setError('')
try {
const list = await window.api.listRemote(sessionId, target)
// A slower earlier listing (breadcrumb click racing a double-click) must
// never overwrite a newer one; only the latest request may paint.
if (seq !== refreshSeqRef.current) return
dirRef.current = target
setEntries(list)
setDir(target)
setSelected(null)
} catch (err) {
if (seq !== refreshSeqRef.current) return
setError((err as Error).message)
} finally {
setLoading(false)
// The spinner belongs to the latest request; a stale one completing
// first must not hide it while the latest is still in flight.
if (seq === refreshSeqRef.current) setLoading(false)
}
},
[sessionId]
@@ -377,7 +390,9 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
if (settled) return
settled = true
off()
void refresh(dir)
// `dir` here is the directory the upload was started from; if the user
// has navigated away since, refreshing it would yank the panel back.
if (dirRef.current === dir) void refresh(dir)
}
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return
@@ -604,8 +619,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}
}
}
// `entries` is read by doUpload's overwrite check — a stale snapshot could
// skip the "file exists" confirmation that protects main's truncating 'w'.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, refresh, language])
}, [menuTarget, dir, entries, refresh, language])
return (
<div className="sftp-panel">
+18 -6
View File
@@ -39,16 +39,24 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
/**
* Paste-risk heuristic, split by shape rather than by raw length:
* - a paste ending in a newline always confirms — the trailing \r is the
* shell's run keystroke, so it executes before the user can review it;
* - two or more lines always confirm — a stray newline executes a command
* the user never reviewed;
* - a single line pastes straight through, unless it is unusually long
* (those are pasted scripts rather than something typed by hand).
* - a single line without a trailing newline pastes straight through, unless
* it is unusually long (those are pasted scripts rather than something
* typed by hand).
*/
const SINGLE_LINE_CONFIRM_LENGTH = 1000
function needsPasteConfirm(text: string): boolean {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
if (normalized.includes('\n')) return true
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
const normalized = text.replace(/\r\n?/g, '\n')
// Decided on the raw shape: stripping the trailing newline first would let
// the most common copy shape (`curl … | sh\n`, one line + newline from a
// doc) straight through to immediate execution.
if (normalized.endsWith('\n')) return true
const trimmed = normalized.replace(/\n$/, '')
if (trimmed.includes('\n')) return true
return trimmed.length > SINGLE_LINE_CONFIRM_LENGTH
}
/**
@@ -378,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 => {
+158 -54
View File
@@ -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)
}
}
+29 -11
View File
@@ -6,43 +6,61 @@ import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
* listeners (N = pane count) and N-1 of them discarded it after an id
* compare. With many terminals under heavy output that per-chunk fan-out is
* pure overhead. Here one IPC listener dispatches through a Map keyed by
* sessionId: one lookup per chunk, and only the owning pane runs.
* sessionId: one lookup per chunk, and only the owning session's panes run.
*
* The per-id value is a Set, not a single handler: an SSH split deliberately
* binds two panes to the same sessionId, so the second pane must not steal
* (and its teardown must not revoke) the first pane's stream.
*/
type DataHandler = (data: string) => void
type ExitHandler = (exitCode: number) => void
const dataHandlers = new Map<string, DataHandler>()
const exitHandlers = new Map<string, ExitHandler>()
const dataHandlers = new Map<string, Set<DataHandler>>()
const exitHandlers = new Map<string, Set<ExitHandler>>()
let bound = false
function ensureBound(): void {
if (bound) return
bound = true
window.api.onPtyData((e: PtyDataEvent) => {
dataHandlers.get(e.id)?.(e.data)
dataHandlers.get(e.id)?.forEach((cb) => cb(e.data))
})
window.api.onPtyExit((e: PtyExitEvent) => {
exitHandlers.get(e.id)?.(e.exitCode)
exitHandlers.get(e.id)?.forEach((cb) => cb(e.exitCode))
})
}
/** Subscribe to one session's output. Returns the unsubscribe function. */
export function subscribePtyData(id: string, cb: DataHandler): () => void {
ensureBound()
dataHandlers.set(id, cb)
let set = dataHandlers.get(id)
if (!set) {
set = new Set()
dataHandlers.set(id, set)
}
set.add(cb)
return () => {
// A rebind (template apply) may have replaced the handler already; only
// delete when this subscription is still the registered one.
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
const set = dataHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) dataHandlers.delete(id)
}
}
/** Subscribe to one session's exit. Returns the unsubscribe function. */
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
ensureBound()
exitHandlers.set(id, cb)
let set = exitHandlers.get(id)
if (!set) {
set = new Set()
exitHandlers.set(id, set)
}
set.add(cb)
return () => {
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
const set = exitHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) exitHandlers.delete(id)
}
}
+19 -18
View File
@@ -30,23 +30,24 @@ const headerKeys = (): FieldSpec[] => [
{ key: 'selectionBackground', label: t('settings.color.selection') }
]
const ANSI_KEYS: FieldSpec[] = [
{ key: 'black', label: 'Black' },
{ key: 'red', label: 'Red' },
{ key: 'green', label: 'Green' },
{ key: 'yellow', label: 'Yellow' },
{ key: 'blue', label: 'Blue' },
{ key: 'magenta', label: 'Magenta' },
{ key: 'cyan', label: 'Cyan' },
{ key: 'white', label: 'White' },
{ key: 'brightBlack', label: 'Bright Black' },
{ key: 'brightRed', label: 'Bright Red' },
{ key: 'brightGreen', label: 'Bright Green' },
{ key: 'brightYellow', label: 'Bright Yellow' },
{ key: 'brightBlue', label: 'Bright Blue' },
{ key: 'brightMagenta', label: 'Bright Magenta' },
{ key: 'brightCyan', label: 'Bright Cyan' },
{ key: 'brightWhite', label: 'Bright White' }
/** Resolved per call (like headerKeys) so a language switch re-translates. */
const ansiKeys = (): FieldSpec[] => [
{ key: 'black', label: t('settings.color.ansi.black') },
{ key: 'red', label: t('settings.color.ansi.red') },
{ key: 'green', label: t('settings.color.ansi.green') },
{ key: 'yellow', label: t('settings.color.ansi.yellow') },
{ key: 'blue', label: t('settings.color.ansi.blue') },
{ key: 'magenta', label: t('settings.color.ansi.magenta') },
{ key: 'cyan', label: t('settings.color.ansi.cyan') },
{ key: 'white', label: t('settings.color.ansi.white') },
{ key: 'brightBlack', label: t('settings.color.ansi.brightBlack') },
{ key: 'brightRed', label: t('settings.color.ansi.brightRed') },
{ key: 'brightGreen', label: t('settings.color.ansi.brightGreen') },
{ key: 'brightYellow', label: t('settings.color.ansi.brightYellow') },
{ key: 'brightBlue', label: t('settings.color.ansi.brightBlue') },
{ key: 'brightMagenta', label: t('settings.color.ansi.brightMagenta') },
{ key: 'brightCyan', label: t('settings.color.ansi.brightCyan') },
{ key: 'brightWhite', label: t('settings.color.ansi.brightWhite') }
]
/** The native colour input only accepts #rrggbb: clamp every seeded value so a
@@ -164,7 +165,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
<div className="theme-editor-divider" />
{ANSI_KEYS.map((spec) => (
{ansiKeys().map((spec) => (
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
</div>
+7 -2
View File
@@ -490,8 +490,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const cleanups = [
api.onDidRemovePanel((panel: IDockviewPanel) => {
// Closing a pane keeps its directory as the seed for the next one.
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
// SSH panes are excluded: their tracked cwd is a *remote* path (OSC 7
// from the remote shell), and seeding a new local terminal with it
// would silently break "start where I left off".
if ((panel.params as TerminalParams | undefined)?.sessionKind !== 'ssh') {
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
}
releaseSession(panel)
recomputeLocalPanels()
recountAll()
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.save': 'Save',
'common.saveFailed': 'Save failed, please try again',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
+23
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': 'Background',
'settings.color.cursor': 'Cursor',
'settings.color.selection': 'Selection',
'settings.color.ansi.black': 'Black',
'settings.color.ansi.red': 'Red',
'settings.color.ansi.green': 'Green',
'settings.color.ansi.yellow': 'Yellow',
'settings.color.ansi.blue': 'Blue',
'settings.color.ansi.magenta': 'Magenta',
'settings.color.ansi.cyan': 'Cyan',
'settings.color.ansi.white': 'White',
'settings.color.ansi.brightBlack': 'Bright Black',
'settings.color.ansi.brightRed': 'Bright Red',
'settings.color.ansi.brightGreen': 'Bright Green',
'settings.color.ansi.brightYellow': 'Bright Yellow',
'settings.color.ansi.brightBlue': 'Bright Blue',
'settings.color.ansi.brightMagenta': 'Bright Magenta',
'settings.color.ansi.brightCyan': 'Bright Cyan',
'settings.color.ansi.brightWhite': 'Bright White',
'settings.font.label': 'Font',
'settings.font.desc': 'The built-in default or a system monospace font',
'settings.font.placeholder': 'Select a font',
@@ -162,6 +178,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'Clear',
'settings.highlight.addBg': 'Add',
'settings.highlight.palette': 'Palette',
'settings.highlight.sampleText': 'Highlight',
'settings.highlight.note': 'Note',
'settings.highlight.notePlaceholder': 'Rule description (optional)',
'settings.highlight.caseInsensitive': 'Ignore case',
@@ -183,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',
@@ -197,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',
@@ -228,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',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'キャンセル',
'common.close': '閉じる',
'common.save': '保存',
'common.saveFailed': '保存に失敗しました。もう一度お試しください',
'common.delete': '削除',
'common.edit': '編集',
'common.add': '追加',
+23
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': 'カーソル',
'settings.color.selection': '選択範囲',
'settings.color.ansi.black': '黒',
'settings.color.ansi.red': '赤',
'settings.color.ansi.green': '緑',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '青',
'settings.color.ansi.magenta': 'マゼンタ',
'settings.color.ansi.cyan': 'シアン',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '明るい黒',
'settings.color.ansi.brightRed': '明るい赤',
'settings.color.ansi.brightGreen': '明るい緑',
'settings.color.ansi.brightYellow': '明るい黄',
'settings.color.ansi.brightBlue': '明るい青',
'settings.color.ansi.brightMagenta': '明るいマゼンタ',
'settings.color.ansi.brightCyan': '明るいシアン',
'settings.color.ansi.brightWhite': '明るい白',
'settings.font.label': 'フォント',
'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント',
'settings.font.placeholder': 'フォントを選択',
@@ -159,6 +175,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'クリア',
'settings.highlight.addBg': '追加',
'settings.highlight.palette': 'パレット',
'settings.highlight.sampleText': 'ハイライト',
'settings.highlight.note': 'メモ',
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
'settings.highlight.caseInsensitive': '大文字小文字を無視',
@@ -179,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 ではありません',
@@ -193,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': '安全',
@@ -223,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': '操作',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '关闭',
'common.save': '保存',
'common.saveFailed': '保存失败,请重试',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '新增',
+22
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '光标',
'settings.color.selection': '选区',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '红',
'settings.color.ansi.green': '绿',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '蓝',
'settings.color.ansi.magenta': '品红',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮红',
'settings.color.ansi.brightGreen': '亮绿',
'settings.color.ansi.brightYellow': '亮黄',
'settings.color.ansi.brightBlue': '亮蓝',
'settings.color.ansi.brightMagenta': '亮品红',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字体',
'settings.font.desc': '内置默认字体或系统等宽字体',
'settings.font.placeholder': '选择字体',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清空',
'settings.highlight.addBg': '添加',
'settings.highlight.palette': '色板',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '备注',
'settings.highlight.notePlaceholder': '规则说明(可选)',
'settings.highlight.caseInsensitive': '忽略大小写',
@@ -173,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',
@@ -186,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': '安全',
@@ -213,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': '操作',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '關閉',
'common.save': '儲存',
'common.saveFailed': '儲存失敗,請重試',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
+22
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '游標',
'settings.color.selection': '選取區',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '紅',
'settings.color.ansi.green': '綠',
'settings.color.ansi.yellow': '黃',
'settings.color.ansi.blue': '藍',
'settings.color.ansi.magenta': '洋紅',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮紅',
'settings.color.ansi.brightGreen': '亮綠',
'settings.color.ansi.brightYellow': '亮黃',
'settings.color.ansi.brightBlue': '亮藍',
'settings.color.ansi.brightMagenta': '亮洋紅',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字型',
'settings.font.desc': '內建預設字型或系統等寬字型',
'settings.font.placeholder': '選擇字型',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清除',
'settings.highlight.addBg': '新增',
'settings.highlight.palette': '色盤',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '備註',
'settings.highlight.notePlaceholder': '規則說明(選填)',
'settings.highlight.caseInsensitive': '忽略大小寫',
@@ -173,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',
@@ -186,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': '安全',
@@ -213,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': '操作',
+2
View File
@@ -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'] },
+7 -4
View File
@@ -144,10 +144,13 @@ writeSettings({ historyLimit: 50, historyEnabled: true })
store.recordCommand('recorded-again')
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
// Default (keys absent from settings.json) records, matching a fresh install.
writeSettings({})
store.recordCommand('default-on')
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
// Default (key absent from settings.json): the shipped default is recording OFF
// (DEFAULT_SETTINGS.terminal.historyEnabled = false, gate is `=== true`), and a
// fresh install has no key at all. The writeSettings helper always injects
// `historyEnabled: true`, so the absence case writes the file directly.
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal: {}, system: {} }), 'utf8')
store.recordCommand('default-off')
ok(!store.listHistory().some((h) => h.command === 'default-off'), 'absent switch defaults to NOT recording (shipped default)')
store.clearHistory()
// ---- 4. Library CRUD + ordering ------------------------------------------------
+48 -1
View File
@@ -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)
+14 -2
View File
@@ -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)
+81
View File
@@ -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')