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.
25 KiB
25 KiB
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
- GitHub Actions:
仓库与远程
origin= Giteahttps://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 使用该令牌。
发布新版本
package.json版本号 +1,写RELEASE_NOTES.md(仓库根,已 gitignore;可选.zh-TW/.en/.ja译文,缺译文回退简体)node scripts/sync-changelog.cjs把发布说明并入CHANGELOG*.md—— 必须在npm run dist之前:更新日志以?raw打进安装包(「关于」页离线读),release.cjs也校验CHANGELOG.md已含该版本号npm run dist构建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 请求)
- Gitea:release(msi + exe 资产)→ Gitea 更新通道(
- 验证更新通道:
curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml应返回新版本号 git tag v<版本号>并推送两个远程(代码/tag 与 release 资产的镜像保持同步)
更新机制
- 检查更新:GitHub 优先(走系统代理;前置 20 秒连通性探测
probeGithub——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partitionelectron-updater),代理模式在useFeed里按源切换 - 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的
release-notes.md读取(直连 sessionopenterminal-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/-hoverCSS 变量,antd token 在main.tsxThemedConfigProvider 派生 - 标签强调色:
settings.tabAccentColor(默认#3fb950),经--tab-accentCSS 变量生效 - 背景图(
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_CHUNK512KB 整块跳过、MAX_LINE_LEN4KB 超长行不跑规则(挡(a+)+b这类回溯)、MAX_PER_RULE300 每条规则每块上限。实测 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