Files
OpenTerminal/AGENTS.md
T
Bill 2653ab1820 feat(i18n): tab auto titles follow the UI language
Titles are stored display text (layout templates, session snapshot,
broadcast registry), so a pane used to keep the wording of the language it
was opened in, and nextTerminalTitle could not even parse the number back
out of another language's pattern (numbering restarted, duplicates).

New pure module src/shared/terminalTitle.ts: resolution tries all four
languages' patterns, rendering uses the active one; i18n gains tFor(lang)
for off-language rendering. Workspace retitles auto-numbered local tabs in
place on language switch, snapshot restore and template apply; SSH panels
(user-typed connection names) are never touched. dockview's title-change
event propagates the new title to the broadcast registry and the snapshot
save with no extra wiring.

New test terminal-title.mjs (16 writer/reader language pairs, non-auto
title boundaries, gap filling); offline suite grows 17 -> 18.
2026-10-08 00:11:20 +08:00

25 KiB
Raw Blame History

OpenTerminal 开发备忘

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,然后依次跑可离线运行的 18 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
  • 依赖分类规则:只有 src/main//src/preload/ 实际 import 的包才能进 dependencies(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,externalizeDepsPlugin 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
  • 下载量统计:node scripts/download-stats.cjs(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 HTTPS_PROXY/本地 7897;更新通道无计数接口不计入)
  • 打包:npm run dist(生命周期先自动跑 predist → npm test,即类型检查 + 18 个离线测试全部通过后才 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

仓库与远程

  • origin = Gitea https://git.codingplan.site/admin/OpenTerminal.git(主仓库 + 更新通道)
  • github = https://github.com/billowliu2/OpenTerminal.git(镜像 + 备用更新源)
  • ⚠️ Gitea 的 upload-pack 有故障,git fetch origin 会报 bad pack header;查询远端状态用 API 或 git ls-remote。推送正常。
  • 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。

凭据

  • .env(已 gitignore):GIT_TOKEN(Gitea)、GH_TOKEN(GitHub)等。git 推送通过 credential helper 使用该令牌。

发布新版本

  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 <版本号>,例如 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 复用、已传资产跳过)
    • 上传前先比通道版本:assertNoDowngrade() 读通道 latest.yml,若线上版本高于待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 pruneChannel 会把线上新版本的 exe/blockmap 删掉
    • 只补通道:node scripts/release.cjs <版本号> --channel-only(不建 release、不发 GitHub)
    • 国内通道全程直连,不需要设代理;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 优先(走系统代理;前置 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
  • 每次 checkForUpdates 都被 30s 整体超时包住(withTimeout):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但被放弃的检查取消不掉,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
  • 更新日志 / releases API 的 fetch 都带 AbortSignal.timeout(15s)(directFetch 与 fetchChangelog 里的 net.fetch):卡住的通道必须让位给下一个来源,不能把「关于」页吊住

主题机制

  • 终端主题由 xterm 主题派生 UI 配色:src/renderer/src/theme/chrome.ts 的 applyChromeTheme 写入 --chrome-bg/-bg-deep/-border/-hover CSS 变量,antd token 在 main.tsx ThemedConfigProvider 派生
  • 标签强调色:settings.tabAccentColor(默认 #3fb950),经 --tab-accent CSS 变量生效
  • 背景图(settings.terminal.backgroundImage):图片层 .term-bg-image 在 xterm 画布之下(terminal.css),xterm 背景设 #00000000 透明。图片模式下的三条铁律:① .terminal-view.has-bg-image .terminal-view-dock 强制深色底衬 #0d1117,不能用 --chrome-bg——浅色主题下它是白色,调低透明度会把图洗白而不是压暗;② minimumContrastRatio 从 1 提到 4.5(WCAG AA,与 VS Code 终端默认值一致;TerminalView 构造与设置热更两处都要改;注意正确拼写是 minimumContrastRatio,minContrastRatio 会被静默忽略),xterm 的对比度计算把透明背景当黑色亮度,会把浅色主题的深色前景自动提亮到可读。无图时保持 1 不动主题配色。③ 压暗层(settings.terminal.backgroundImageDim,0–90,默认 0):.term-bg-dim 纯黑 scrim 在 DOM 序上位于 .term-bg-image 之后、xterm(z-index:1)之下(同在 z-index:0 层,靠 DOM 序压图),与透明度的区别是透明度把图与深色底衬混合、压暗在图之上叠黑(保饱和但整体变暗);dim=0 时 TerminalView 不渲染该层,零成本

终端尺寸同步

  • TerminalView.scheduleFit:fit 后去抖 100ms 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
  • 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效

性能与安全边界(M11 第二轮)

  • TerminalView 用 5 组 useShallow 字段级订阅(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),不要再回到整对象订阅——主进程 mutateSettings 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。useResolvedTheme 也是 shallow + memo 后的稳定引用
  • TerminalView 已无 imperative handle(TerminalHandle/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入
  • FilePanel 虚拟化:固定行高自实现 windowing(>200 条目启用)。.sftp-row 的 height: 24px(sftp.css)与 FilePanel.tsx 的 ROW_HEIGHT 必须同步改——本仓库没有全局 box-sizing reset,行高写成 box-sizing: border-box 是有意的,去掉会让虚拟列表算术错位
  • 每个 dockview pane 内有 PanelErrorBoundary(ErrorBoundary.tsx):pane 崩溃只卸载自己,根部边界仍兜底
  • 本地路径准入(src/main/localPathGrants.ts):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时必须过这道门,别绕
  • keyPath(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸)
  • src/shared/reservedAccelerators.ts:设置页录制器与主进程 applyGlobalShortcut 共用同一张保留键表(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
  • SFTP 操作有 per-op 超时(sftp.ts 的 bounded():元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;setSftpTimeouts/setUpdateTimeouts 是测试缝,生产无调用者,别接设置项
  • webPreferences 显式写死 contextIsolation: true / nodeIntegration: false / webSecurity: true(index.ts 唯一窗口创建点),防默认值被将来改动
  • 终端标签自动标题(「终端 N」)是存储的显示文本(进布局模板/会话快照/广播注册),src/shared/terminalTitle.ts 负责两个方向:反解用全部 4 语言的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 retitleAutoTitles 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 tFor(lang, key, vars)

锁屏

  • 只使用主窗口内的不透明遮罩(src/renderer/src/lock/LockScreen.tsx + lock.css 的 .lock-screen,z-index 4000),不创建第二个 Electron 窗口。锁定时 App.tsx 把 .app-root 设为 inert 并保持挂载——卸载会杀掉遮罩后面的本地/SSH 会话与传输列表;antd portal(Modal/Dropdown/Tooltip)挂在 document.body 上、不在 #root 内,锁定时要把 body 下 #root 以外的子节点也设为 inert,否则键盘 Tab 仍能走进遮罩后面的浮层。这个一次性快照不够:锁定之后才挂上的 portal 不在其中(典型场景是 SSH 连接发出后闲置自动锁屏,主机密钥弹窗此刻才弹出,antd 的 autoFocus 还会抢走密码框焦点),所以锁定期间 App.tsx 用 MutationObserver 盯着 document.body 的 childList,给新加的非 #root 子节点补 inert,解锁时断开 observer 并按记录恢复;observer 只在锁定时存在,平时零开销,且锁屏遮罩自身在 #root 内,永远不参与 inert
  • 密码 verifier 在 <userData>/lock.json(src/main/lockStore.ts 的 LockStore):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + timingSafeEqual,不存明文;缺 version: 1、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 version 不认识会每进程 warn 一次——将来改格式不许静默失锁
  • 锁状态由主进程独占(src/main/lockController.ts):LockSettingsState 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,salt/hash/密码永不出主进程,渲染层从不自行判定锁定
  • 锁标志落盘在 <userData>/lock-state.json(LockStateStore),locked/failures/cooldownUntil 每次变化立即写,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——lockAtStartup 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 cooldownUntil 夹紧到 now+30s,防系统时间回拨导致永久锁死
  • 冷却阶梯 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 把遮罩后面的会话全杀掉。键盘判定抽在纯函数模块 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 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键。主进程侧也拒绝注册它:applyGlobalShortcut(globalShortcuts.ts)对 Control+L / Ctrl+L / CommandOrControl+L(大小写、修饰键别名都不敏感)直接跳过并 warn——录制器只拦得住它上线之后录入的值,老版本存下的 Control+L 仍会走到注册这一步
  • 启动时不要用 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-shortcuts.mjs(键盘分类器表驱动用例)

布局模板(工作区)

  • 应用模板(Workspace.handleApplyTemplate)读的是外来 JSON。fromJSON 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会先把目标 dockview 清空再抛错(failed to deserialize layout. Reverting changes)。清空是逐面板走 onDidRemovePanel 的,所以旧会话在抛错之前就已经被 killSession 杀掉了——恢复出来的面板接不回它们,必须换新会话
  • 失败路径:应用前 toJSON() 快照两个 dockview → 抛错时只对这次真的调用过 fromJSON 的 dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 rebindRestoredPanels → 再把「没有任何面板引用的 previousSessions」kill 掉 → message.error。JSON.parse 失败同样要提示,不能静默 return
  • 会话计数不靠累加器:releaseSession 直接扫 api.panels 判断还有没有面板在显示该会话——累加器与「updateParameters 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀

关键词高亮

  • 预设规则表在 src/shared/settings.ts 的 DEFAULT_HIGHLIGHT_RULES(22 条),引擎在 src/renderer/src/terminal/highlightEngine.ts;规则按 priority 升序应用,先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过
  • 状态类预设(danger/okstate/warnstate/badstate)priority 排在 shellkw 之前:done 既是 shell 关键字又是成功词、if 还在 dd if= 里,先跑谁就由谁着色
  • 状态符号(✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠)不能放进 \b…\b 组:\b✓ 永不成立。预设把它们写在 \b(?:…)\b|[✓✔✅]\uFE0F? 的第二个分支里,尾随的 \uFE0F? 是为了把 emoji 变体选择符一起圈进着色范围
  • 严重级别按颜色拆开:成功(okstate 绿)/ 警告(warnstate 黄)/ 错误·致命(badstate 红,含 CRITICAL、FATAL、PANIC)/ 删除·移动·覆盖(delop 橙)/ 新建·创建·安装(createop 亮绿);badstate 里的 NOT … 分支负责 not ok / not found,okstate 的反向断言保证它不会被染绿
  • delop/createop 只列操作动词(delete/remove/rm/mkdir/touch/add/install/clone/…),export 等 shell 关键字仍归 shellkw,别把两边都写进去抢 span
  • 数值分级(bands):匹配里的第一个数字决定颜色(最后一个 min <= 值 的分级胜出),无数字或低于最小分级时回落到 color.fg;预设 percent 用它做百分比(<20% 红 / 20–50% 黄 / 50–80% 浅绿 / ≥80% 绿)。它的 priority 22 必须早于 numbers(25),否则 45% 会先被数字规则整段吃掉;numbers 规则本身不含百分比分支
  • 词干要自带词尾(DELET(?:E|ED|ES|ING|ION) 而非 DELETE(?:D|S|ING)?,否则漏 deleting);不成词的词干(MOV/SAV/CLON/PURG/ERAS/WIP/REVOK)必须强制要求词尾
  • 上下文敏感的规则用 lookbehind/lookahead 只圈住关键词本身,否则分级会读到错误的数字:(?<=\bHTTP/\d(?:\.\d)?\s)[1-5]\d\d\b 让 HTTP/1.1 404 只着色 404(若把 HTTP/1.1 一起匹配,bands 会读到版本号 1)。同理 [1-5]\d\d(?=\s+OK|…) 靠先行断言限定"后面跟原因短语"才算状态码,避免把 123 这类普通数字当成 404
  • 裸 3 位数([1-5]\d\d)不能单独成规则,必须有上下文锚点;同理 MAC、短哈希这类高误伤模式不进预设
  • 导入 / 导出与实时预览:src/shared/highlightIO.ts 管 JSON 信封(kind/version,外来 JSON 直接拒绝)与 replace/append 合并;渲染层用剪贴板 + FileReader + Blob 下载完成,不新增 IPC。编辑器预览走引擎的 previewSpans(跑真实高亮再解析它自己的 SGR 输出),并且带上当前其它规则,这样 span 被别的规则抢走时能一眼看出来
  • 分类(category,safety|status|file|net|text|metric):预设的分类放在 PRESET_CATEGORIES 一张表里,basic 集合必须始终是 safety+status 的子集(有测试守);settings.terminal.highlightGroupByCategory 只影响设置页(加分类列 + 按分类聚簇,组内仍按 priority),热路径完全不涉及
  • 跟随主题(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.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
  • 词边界是这套预设的全部难点:新增关键词后请跑 node tests/.hl-rules.cjs(invalid 不能点亮 valid、disabled 不能点亮 enabled 等)
  • 升级旧安装:settingsStore.refreshBuiltinRules 只把仍是出厂 pattern 的内置规则升到新预设(保留用户的颜色/优先级/启停),且仅当规则集恰好等于旧预设集时才追加新增的内置规则——否则用户删掉的规则会每次加载都复活。改预设 pattern 时同步更新 LEGACY_BUILTIN_PATTERNS / LEGACY_BUILTIN_IDS