Compare commits

..
1 Commits
Author SHA1 Message Date
Bill cee616a23a feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
CI / typecheck + test + build (windows) (push) Waiting to run
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 bug

**设置越界导致窗格永久死亡(用户报告)**
行高设 0.95 → xterm 构造/热更抛错 → 错误边界卸载整个 dockview 窗格;坏值已
落盘,每次重启都崩。根因是一整类缺陷:UI 允许值 / 配置能写值 与 xterm 硬约束
不一致,且主进程不校验。同类还有 scrollback<0(抛错)、cursorStyle 非法值
(更隐蔽:构造期被吞掉、只有热更期抛错,导致「重试」按钮永远无效、窗格死循环)。

修复:FONT_LIMITS/CURSOR_STYLES/RENDERER_MODES 收拢到 shared 层单一来源,
主进程加载时钳制 + 白名单化,healClampedFontSettings() 回写自愈;设置页
InputNumber、字体热键共用同一常量(照 LOCK_AUTO_DELAYS 先例,杜绝再次漂移)。

**perm 预设锚定失效**
^[-dlbcps]... 的 ^ 锚定的是「转义序列之间的 text run 起点」而非行首,ls -l
多行输出只有第一行权限位上色;带颜色的 ls(run 以转义切开)完全不触发。
改为 (?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])——用负向前瞻而不是
(?=\s|$) 是因为后者会让带 ACL `+` / macOS `@` 后缀的真实 ls -l 输出不再染色。
旧 pattern 登记进 LEGACY_BUILTIN_PATTERNS,存量安装下次启动自动升级。

**高亮 span 吃掉应用自身 SGR**
wrap 固定追加 \x1b[0m,把应用设的颜色一并清掉。改为 SgrState 跟踪并重放。
四条护栏(每条都踩过坑,详见 AGENTS.md):
- reset 判定解析参数:ESC[00m/ESC[0;0m/ESC[0;32m(Go/Java 日志库常写)原先
  被追加进累积器,逐行增长直到撞上限,之后所有 span 恢复永久失效
- 回放闸门按序列条数而非字节数:同一个"单个前景色"长度差近 4 倍,按字节卡
  会砍掉真彩色却放过索引色
- 恢复串只插在可见间隙和最后一个 span 后:每 span 都重放会让 Kimi 这类全彩
  TUI(~19 span/行)把 9.5KB 输出膨胀到 40.8KB(4.30x),渲染线程实测卡
  18.4 秒。现为 3.05x 且长间隙着色正常
- 基准测试守门(膨胀 < 3.2x),改参数必须重跑

**CSI 私有参数导致整行失去高亮**
ESCAPE_RE 漏了 <>= 前缀与中间字节,\x1b[>4;1m(modifyOtherKeys)、
\x1b[<0;1;2M(SGR 鼠标)、\x1b[=1;1$y(DECRQM) 让整行成含裸 ESC 的文本,该行
之后所有关键词失去高亮(vim/tmux 常发)。

**超长行拖垮整段高亮**:一条 >4KB 的行让整个 text run 失去高亮。改为逐行降级。

**统计「最后写入者赢」**:模块级 totals 被各 pane 覆盖,设置页数据在窗格间
跳动。改为按 pane 实例 UUID 聚合(不能用 sessionId——SSH 分屏同一 session 挂
两个 pane,正好复制该 bug)。

## 新增能力

- **CJK 对齐三件套**:@xterm/addon-unicode11@0.10.0-beta.301(peer 恰好接受
  锁定的 6.1.0-beta.304,升 xterm 必须同步重配)、rescaleOverlappingGlyphs、
  cjkFontFamily 回退槽位。默认字体栈没有任何 CJK 字体,中文靠 Chromium 回退
  到比例字体导致列错位
- **字体名引号包裹**:quoteFontStack() 逐段判定(含反斜杠转义——只转义 " 会
  让尾部反斜杠把闭合引号转义掉,CSS 字符串不终止)
- **等宽徽标**:canvas measureText 逐字符采样,等宽排前
- **规则按行匹配**(lineAnchor → m flag)
- **命中提醒**(notify + onMatch 事件出口,60s 冷却,只显示规则名不显示原文)
- **可点击链接规则**(WezTerm hyperlink_rules 式,安全闸门在生成 span 时就
  丢弃非 http/https 目标)

## 其它

- 主题语义变量改为单一命名体系:semanticVars() 键就是最终 CSS 属性名,字面量
  键联合让拼错成为编译错误;删除 cssVarName()/semanticCssVars() 中间层
  (自定义属性名大小写敏感,转换被绕过会静默回落到深色兜底值)
- 删除死代码:automaticFontFallback 与顶层 overviewRuler 在 6.1.0-beta.304
  中均不存在,且移除了让任何伪造键都能通过 tsc 的 as 断言
- ReDoS 防护(MAX_PATTERN_LEN + 编辑器 backtracksBadly 实时探测)——注意这
  只是止血,(a+)+$ 配 30 字符实测仍卡 8.2 秒,根治需把正则执行挪进 Worker

测试 29 → 32(新增 font-stack、link-rules),全部通过;AGENTS.md 已同步。
2026-10-17 20:23:56 +08:00
29 changed files with 3071 additions and 216 deletions

No files matched your search

+33 -3
View File
@@ -7,10 +7,10 @@ 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,然后依次跑可离线运行的 29 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、write-batcher、refit-throttle、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 31 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names、font-stack、link-rules;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、font-stack、write-batcher、refit-throttle、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules、link-rules 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
- 依赖分类规则:**只有 `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`,即类型检查 + 29 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 31 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(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`
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
@@ -71,11 +71,19 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 主题机制
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
- **语义变量层(2026-10 主题改造)**:`src/renderer/src/theme/semantic.ts`(**零 import 纯模块**——`tests/semantic-theme.mjs` 经 Node 原生类型剥离直连它,加 import 前先想清楚)导出 `isLightTheme`/`mix`/`luminance`/`contrast`/`darkenToRatio`/`SEMANTIC` 锚点;`chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme: dark|light`(原生滚动条/表单跟随)。**深色分支锁 SEMANTIC 锚点原值(零回归),浅色分支 `darkenToRatio` 按 WCAG 派生**(正文/-fg ≥4.5:1,语义基色 ≥3:1)。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(剥注释/color-mix/var 兜底走受控清单 FALLBACK_OK,跳过含 `${` 的模板串行,白名单按「文件+精确值」登记在测试内);**canvas 绘制吃不到 CSS 变量**(`ctx.strokeStyle` 不解析 var()),图表类组件用 `useResolvedTheme()` + `semanticVars()` 取实色(MonitorPanel 的做法)。**变量名大小写敏感**:`semanticVars()` 的键是 camelCase 而 CSS 读的是 kebab-case,`chrome.ts` 必须经 `semanticCssVars()`(内部 `cssVarName()`)转换后再写——直接写 camelCase 名**不报错**,只是该变量永不生效、元素静默回落到 `:root` 的深色兜底值(浅色主题就这么坏过一轮:`--ot-text-muted` 等 9 个多词变量全部没生效)。`node tests/ot-var-names.mjs` 把「CSS 引用名 = JS 写入名 = global.css `:root` 声明名」三个集合锁在一起——**值测试(semantic-theme/theme-contrast)看不出这类问题**,改语义层时别只跑它们
- **语义变量层(2026-10 主题改造)**:`src/renderer/src/theme/semantic.ts`(**零 import 纯模块**——`tests/semantic-theme.mjs` 经 Node 原生类型剥离直连它,加 import 前先想清楚)导出 `isLightTheme`/`mix`/`luminance`/`contrast`/`darkenToRatio`/`SEMANTIC` 锚点 + `OtVarName`/`SemanticVars` 类型;`chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme: dark|light`(原生滚动条/表单跟随)。**深色分支锁 SEMANTIC 锚点原值(零回归),浅色分支 `darkenToRatio` 按 WCAG 派生**(正文/-fg ≥4.5:1,语义基色 ≥3:1)。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(剥注释/color-mix/var 兜底走受控清单 FALLBACK_OK,跳过含 `${` 的模板串行,白名单按「文件+精确值」登记在测试内);**canvas 绘制吃不到 CSS 变量**(`ctx.strokeStyle` 不解析 var()),图表类组件用 `useResolvedTheme()` + `semanticVars()` 取实色(MonitorPanel 的做法)。**单一命名体系(v1.0.29 修复)**:`semanticVars()` 的键**就是最终 CSS 自定义属性名**(`'--ot-text-muted'`),返回类型 `SemanticVars = Record<OtVarName | 'color-scheme', string>` 是字面量键联合——**曾经是 camelCase 键 + `cssVarName()`/`semanticCssVars()` 转换桥,自定义属性名大小写敏感,转换一被绕过就静默回落到 `:root` 深色兜底值(浅色主题下 `--ot-text-muted` 等 9 个多词变量全部没生效过一轮)**;现在中间层已删除,键名拼错在读取处直接是 TS 类型错误,`node tests/ot-var-names.mjs` 另外断言每个键匹配 `/^--ot-[a-z0-9-]+$/`(camelCase 混回来即 FAIL)并把「CSS 引用名 = JS 写入名 = global.css `:root` 声明名」三个集合锁在一起——**值测试(semantic-theme/theme-contrast)看不出这类问题**,改语义层时别只跑它们。加变量时 `OtVarName` 联合与 `semanticVars()` 两个分支三处一起改
- **浅色主题可读性**:内置浅色主题 ANSI 调色板已重做,16 色+fg 全部 ≥4.5:1(`node tests/theme-contrast.mjs` 守门,改浅色主题必须过它;改色用 `darkenToRatio` 统一加深算法,不许手挑色);`minimumContrastRatio` 按「背景图 4.5|浅色无图 4.5|深色无图 1」矩阵(构造+热更两处都要改),`drawBoldTextInBrightColors` 浅色关闭;WebGL 渲染器同样执行 MCR(addon-webgl 自带 `_getMinimumContrastColor` 对比度路径,已核实);antd 算法随 `isLightTheme` 切 `defaultAlgorithm`/`darkAlgorithm`(**antd 没有 lightAlgorithm,浅色就是默认算法**);github-light 底色已柔化 `#f6f8fa`
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效;`--tab-accent-fg` 是强调底上的文字色(明暗自适应,选中态按钮文字也用它)
- 背景图(`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 不渲染该层,零成本**
## 终端字体
- **引号包裹在消费点做,settings 存原样**:`src/renderer/src/terminal/fontStack.ts` 的 `quoteFontStack`(零 import 纯模块,`tests/font-stack.mjs` 直连)把字体栈逐段判定——裸标识符合法且非 CSS 关键字则裸写,其余(含空格/括号/`+`/非 ASCII/数字开头)双引号包裹,幂等。**只在传给 xterm(构造+热更)与预览 inline style 时调用**,settings.json 里保持用户原文。枚举端 `font-list` 的 `disableQuoting: true` 不动
- **CJK 回退槽位**:`settings.terminal.cjkFontFamily`(默认 `''`=不追加)。TerminalView 的 `appendCjk` 先拼栈再 `quoteFontStack`(追加在引号包裹**之前**,一次切分)。fontOpts 的 useShallow 选择器含该字段,改中文字体即时热更
- **`@xterm/addon-unicode11@0.10.0-beta.301` 精确锁定**(devDependencies):peer 恰好接受 `6.1.0-beta.304`,**升 xterm 必须同步重配 addon 版本**(beta.302+ 的 peer 已不接受 304)。`term.loadAddon` 后 `unicode.activeVersion = '11'`;addon 随 `term.dispose()` 连带清理(AddonManager 已注册),不用单独存 ref
- `rescaleOverlappingGlyphs: true`(**仅 WebGL 渲染器有效**,DOM 渲染器按浏览器布局不受影响)——把超出 cell 步进的字形压回格内,box-drawing/CJK 宽字符不再压到邻格
- 字体下拉等宽徽标:SettingsTabs 用离屏 canvas `measureText` 逐字符采样(`i1l.,:;|Wm0@`,容差 0.01,测失败判非等宽)——误判只会漏徽标不会错标;等宽排前面、「默认」永远第一;`filterOption` 只匹配 `searchText ?? value`,徽标文本不进搜索
## 终端尺寸同步
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
@@ -133,6 +141,16 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 上下文敏感的规则用 **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 被别的规则抢走时能一眼看出来
- **锚定语义(改预设 `^…` 前必读)**:规则只作用于「转义序列之间的 text run」,且默认**没有 `m` flag**——裸 `^` 锚定 run 起点而非行首(`perm` 预设曾因此整类失效)。行首锚定用 lookbehind `(?<=^|\s)`;规则可开 `lineAnchor: true`(schema 字段,编译成 `gm`/`gim`,编辑器有「按行匹配」开关,徽标 `^$`),让用户的 `^ERROR$` 按行生效
- **span 结束恢复应用 SGR**:引擎给命中 span 收尾时不再是裸 `\x1b[0m`,而是 `\x1b[0m` + 重放进入 span 前活跃的 SGR 序列。状态由 `HighlightStream` 持有并跨 push 传递——`hl-split-smoke` 的逐切分点字节一致依赖这一点,动 `applyHighlights` 第 4 参(sgr 状态)签名前先想清楚。四条护栏(全部踩过坑,别拆):
- **reset 判定必须解析参数**,不能字符串全等。ECMA-48 里 `ESC[m`/`ESC[0m`/`ESC[00m`/`ESC[0;0m` 都是全量复位,`ESC[0;32m`(复位+设色,Go/Java 日志库常写)要先清空再记录新色。只认两种规范写法会让其它拼法被**追加**进累积器,每行涨几字节直到撞上上限,之后所有 span 的恢复永久失效
- **回放闸门按序列条数**(`MAX_REPLAY_SEQUENCES=3`)而不是字节数。同一个「单个前景色」的写法长度差近 4 倍(`ESC[31m` 5B / `ESC[38;5;196m` 11B / 真彩色 13–19B),按字节卡会砍掉真彩色却放过索引色,语义不一致。`MAX_RESTORE_LEN=256` 只是累积器的内存上限
- **恢复串只插在「可见间隙」前后**(`MIN_GAP_FOR_RESTORE=3` 个可见字符),不是每个 span 后。Kimi 这类全彩 TUI 一行 ~19 个 span,每 span 都重放会让 9.5KB 输出膨胀到 40.8KB(**4.30x**,无恢复时 2.78x),下游 xterm 解析 + WebGL 字形图集(背景图模式 MCR=4.5 逐字形算对比度,真彩色让缓存全 miss)被压垮,实测渲染线程卡 **18.4 秒**(日志 `[renderer] main thread stalled ~18384ms`)。现在的实现:最后一个 span 后必恢复,中间间隙够长才恢复,膨胀 3.05x 且长间隙着色正常
- **改以上任何参数必须重跑 `[restore cost budget]` 基准段**(`tests/hl-rules.mjs`,断言密集真彩色 TUI 膨胀 < 3.2x)。功能性用例全绿不代表热路径没回归——`hl-split-smoke` 的样本每行以 reset 结尾,恰好绕过了这类膨胀
- **CSI 参数类必须含私有前缀与中间字节**:`ESCAPE_RE` 的 CSI 分支是 `[0-9;:?<>=]*[ -/]*[A-Za-z]`。漏掉 `<`/`>`/`=` 和中间字节(`ESC[>4;1m` modifyOtherKeys、`ESC[<0;1;2M` SGR 鼠标、`ESC[=1;1$y` DECRQM)会让整行被当成含裸 ESC 的文本透传,**该行之后直到下一个换行的所有关键词全部失去高亮**——vim/tmux 在鼠标与按键上报时正好发这些序列
- **用户正则没有真正的 ReDoS 防护**:`MAX_LINE_LEN=4KB` 限的是长度不是回溯成本——`(a+)+$` 配 30 个字符实测卡 **8.2 秒**(28 字符 2 秒,指数级)。现有防线是 `MAX_PATTERN_LEN=500` 限幅 + 编辑器 `backtracksBadly()` 探测(用 4 组诱发输入实跑,超 50ms 预算就提示),**都拦不住真正的灾难性回溯**。要正解需把预览/执行挪进 Worker 加超时,已列为待办
- **超长行按行降级**:run 无 >4KB 行时走整段扫描(保留跨行匹配);有则按 `\n` 逐行,>4KB 的行原样透传不上色(该 run 内跨行匹配放弃)。回溯防护不变式:**任何 >4KB 的行不进用户正则**
- **统计按 pane 实例聚合**:`highlightStats.ts` 按 pane id(`crypto.randomUUID()`,**不是 sessionId**——SSH 分屏同一 session 挂两 pane)登记各自 sink,订阅方拿跨 pane 求和;卸载与关统计开关都要注销,重绑定(rebind)**不**注销(React 先跑完 cleanups 再 setups,在那里注销会让 pane 从汇总消失一拍)。重置清各 pane 的真实计数器
- 分类(`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 格式化)
@@ -143,3 +161,15 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- `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`
## 可点击链接规则
- 内置链接(`src/renderer/src/terminal/urlLinks.ts` 的 `findUrls`,4 条写死的正则:http(s) / ftp / `www.` / 裸 host:port)**不动**;用户自定义规则是叠加在其上的第二来源
- 引擎在 `src/shared/linkRules.ts`(**零 import 纯模块**,`tests/link-rules.mjs` 直连):`sanitizeLinkRules` 清洗(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、`enabled` 缺省为 true、note 截断 60、上限 100 条)、`formatLinkTemplate` 展开模板(`$0` 整段 / `$1`–`$9` 分组 / `$$` 字面 `$`,**只吃一位数字**所以 `$10` = 分组 1 + 字面 `0`,未参与分组展开为空串而非 `undefined`)、`compileLinkRules`、`findRuleLinks`、`mergeLinkSpans`
- **安全闸门 = `isSafeLinkTarget`(同文件)**:生成 span 时就把非 `http:`/`https:` 目标丢掉,`file://` / `javascript:` / `ms-settings:` / 相对路径连下划线都不会出现(不是等点开才拦)。主进程 `openExternal` 的协议白名单是第二道,两个都要在
- 接线点在 `TerminalView.tsx` 的 link provider:`mergeLinkSpans(findRuleLinks(text, linkRulesRef.current), findUrls(text))`——**用户规则优先,重叠的内置链接让位**;hover 提示 `text: link.url` 显示的是展开后的真实目标
- **热更走 `linkRulesRef`**(照 `streamRef.setRules` / `notifyRulesRef` 的模式):provider 随终端注册一次,改规则只换 ref 指向,绝不重建终端。`EMPTY_LINK_RULES` 是共享的稳定空数组——选择器返回新建的 `[]` 会让 zustand 每次拿到新身份,任何无关设置写入都会重渲染所有 pane
- 性能护栏:`LINK_TEXT_MAX` 4KB 以上的逻辑行整行跳过(内置正则是固定集合,用户正则可回溯爆炸);每规则每行上限 200 个匹配;能匹配空串的 pattern 强制 `lastIndex` 前进,避免死循环
- UI 在 `HighlightTab.tsx` 底部的 `ClickableLinkRules`(与高亮同属「终端输出的模式匹配」心智):表格(启用/正则/模板/操作)+ 编辑弹窗(pattern 非法正则内联报错、template 非 http(s) 内联报错、note、启用)。导入导出与 profile 联动**尚未做**,属后续可加
- 设置字段 `AppSettings.linkRules?: LinkRule[]`(可选,默认 `[]`);三处接线照 `highlightProfiles`:`src/main/settingsStore.ts` 的 `deepMerge` 分支 + `sanitizeLinkRules` 调用 + `defaultSettings()`,渲染层 `settings/store.ts` 的 `setLinkRules` + persist 白名单
- 测试:`node tests/link-rules.mjs`
+181
View File
@@ -0,0 +1,181 @@
# 字体与关键词高亮改进计划(2026-10-17)
基于 2026-10-17 的专项调研(字体两轮 + 高亮两轮,子代理实测探针交叉印证,主模型复核关键结论)。所有问题均给出证据位置与验收口径,按批次执行。
## 调研结论摘要
**字体**:能力面已全(family/字号/字重/粗体字重/字间距/行高 + 预览 + 热更 + 热键),WebGL 字形图集在字体变化时自动重建(无缺陷,不需要 `clearTextureAtlas`)。核心短板:默认字体栈无 CJK 字体、无 unicode11 宽度表、无 `rescaleOverlappingGlyphs`,中文环境列对齐无保障;字体下拉是系统全量字体且无等宽标记,与「系统等宽字体」文案矛盾。
**高亮**:引擎与规则体系在「用户自定义正则高亮」层领先 VS Code / Windows Terminal;性能实测 200 条规则 × 135KB 文本 ≈ 11ms,规则数量非瓶颈。核心短板:两个现成 bug(`perm` 预设锚定失效、注入的 `\x1b[0m` 清掉应用自身样式);「匹配后动作」能力为零(secret 只上色不设防);`m` flag 不可用;预设升级机制只覆盖 2 条旧 pattern。
---
## P0 正确性修复(bug,优先做)
### P0-1 `perm` 预设锚定失效(本轮实施:Qwen 任务 A)
- **问题**:`src/shared/settings.ts:299` 的 `^[-dlbcps][rwxstST-]{9}` 中 `^` 锚定的是「转义序列之间的 text run 起点」而非行首。`ls -l` 多行输出只有第一行权限位上色;带颜色的 `ls`(run 以 `\x1b[32m` 切开)完全不触发。`tests/hl-rules.mjs` 无 perm 用例。
- **方案**:pattern 改为 `(?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])`(lookbehind 锚定行首/空白边界;trailing 用负向前瞻拒绝「再跟一个权限字符」——不能用 `(?=\s|$)`,会把 ACL/SELinux `+`、macOS `@` 后缀的真实 `ls -l` 输出挡掉,这是审核轮抓到的回退);旧 pattern 登记 `src/main/settingsStore.ts` 的 `LEGACY_BUILTIN_PATTERNS`,让存量安装升级;补 perm 测试用例(多行 `ls -l`、带色输出、单词内不误配、11 字符不截断、ACL `+`/macOS `@` 后缀仍染色)。
- **验收**:`npm test` 全绿;新增用例覆盖上述场景。
- **风险**:10 个连续连字符(markdown 分隔线 `----------`)会误配——触发面极窄,接受;已知带 ACL/SELinux 后缀的权限串不再染色的问题已由负向前瞻写法解决。
- **状态**:✅ 已完成(Qwen3.8-27B 实施 → flash 审核发现 ACL 回退 → 主模型定稿 pattern `(?![rwxstST-])` 并补 2 个后缀用例,`npm test` 全绿)。
### P0-2 注入的 `\x1b[0m` 清掉应用自身 SGR 样式
- **问题**:`src/renderer/src/terminal/highlightEngine.ts` 的 `wrap` 给每个命中 span 固定追加 `\x1b[0m`。实测 `\x1b[33mWARNING tail` → `tail` 丢失应用的黄色。
- **方案**:tokenize 时跟踪当前活跃 SGR 状态(进入 span 前记录自上次转义以来的 SGR 序列,span 结束后重放而非裸 reset)。只对 text→seq 边界内做,保持 `hl-split-smoke` 逐字节兼容。
- **验收**:新增引擎用例:`\x1b[33mWARNING tail` 染色后 `tail` 仍为黄色;`hl-split-smoke` 全绿。
- **⚠ 实施后的性能回归与修正(2026-10-17 实测)**:初版只设 `MAX_RESTORE_LEN=256`(累积器上限),结果恢复串在**每个 span 后**都重放,代价是 `串长 × span 数`。Kimi 这类全彩 TUI 一行 ~19 个 span,19 字节真彩色串使 9.5KB 输出膨胀到 40.8KB(**4.30x**,改动前 2.78x);叠加用户配置(背景图 + WebGL + 高亮 all 档,MCR=4.5 逐字形算对比度且真彩色令缓存全 miss),渲染线程卡 **18.4 秒**(日志 `[renderer] main thread stalled ~18384ms`,用户报「输入 kimi 要很久才加载出来」)。**修正**:新增独立成本闸门 `MAX_REPLAY_COST=11` 字节(按「收益能否覆盖长度×span 数」,而非串本身的绝对长度),≤11 字节(单色/单属性)仍回放、更长退回裸 reset 即改动前行为。实测膨胀回到 2.78x(与改动前一致),收益场景 `\x1b[33m` 仍正确保留。教训:**功能性用例全绿不代表热路径没回归,改这段必须跑 TUI 密集输出的膨胀基准**
### P0-3 多窗格统计「最后写入者赢」
- **问题**:`src/renderer/src/terminal/highlightStats.ts` 的模块级 `totals` 被每个 pane 的 1s 周期发布覆盖,设置页数据在窗格间跳动。
- **方案**:按 pane 汇总(key 为 session/pane id)或改为累加合并;设置页展示汇总值。
- **验收**:两个 pane 同时输出时统计稳定不跳动。
### P0-4 字体死代码:`automaticFontFallback`(本轮实施:Qwen 任务 B)
- **问题**:`src/renderer/src/terminal/TerminalView.tsx:878` 的 `automaticFontFallback: true` 在 @xterm/xterm 6.1.0-beta.304 中不存在(typings 与 lib 均无),是空操作,且靠 `as Terminal['options'] & object` 绕过了类型检查,误导维护者。
- **方案**:删除该行;若该 as-cast 仅为容纳此未知选项而存在则一并删除。`tsc -p tsconfig.web.json` 验证。
### P0-5 `disableQuoting: true` 去掉字体名引号保护
- **问题**:`src/main/ipc.ts:224` 显式关闭 font-list 的引号包裹,含 `()`/`+`/数字开头/等于 CSS 关键字的字体名在 CSS/canvas 解析中可能失败。
- **方案**:保持枚举原样,渲染层消费点(写入 settings 前或传给 xterm 前)对字体栈逐段 `JSON.stringify` 包裹非法标识符段(实现为纯函数,两端复用)。
### P0-6 `overviewRuler` 顶层写法无效 + `as` 断言可绕过任何伪造选项(审核轮新发现)
- **问题**:`TerminalView.tsx` 构造 Terminal 时顶层的 `overviewRuler: { width: 9, ... }` 不是 6.1.0-beta.304 的顶层选项——它属于 `IScrollbarOptions`,正确写法是 `scrollbar: { width: 9, overviewRuler: {...} }`。顶层键会被 OptionsService 构造**静默丢弃**(实跑验证 `new Terminal({overviewRuler}).options.overviewRuler === undefined`),当前滚动条宽度实为 xterm 默认值。另外 `as Terminal['options'] & object` 断言能让**任何**伪造键通过 tsc(审核实测 `totallyBogusOption` 也能过)。
- **方案**:决定产品意图——若想要 9px 滚动条 + overview ruler,改写为 `scrollbar: {...}` 并验证视觉效果;否则删除。删除后 `as` 断言可一并移除(审核实测:去掉 overviewRuler 后无断言也能过类型检查)。注意 `withChromeColors` 里的 `overviewRulerBorder` 是 `ITheme` 合法键,**有效,不要误删**。
- **验收**:tsc 零错误;若启用 `scrollbar.overviewRuler`,视觉验证滚动条与 ruler 表现。
---
## P1 高价值低成本
### P1-1 编辑器「按行匹配」开关(暴露 `m` flag)
- 引擎 `compileRules` 只拼 `g`/`gi`(`highlightEngine.ts:131`)。规则 schema 加 `lineAnchor?: boolean`,编译时追加 `m`;编辑器加开关。顺手化解 P0-1 类问题对用户自写 `^...$` 规则的困扰。
### P1-2 CJK 宽度与对齐三件套(字体侧最大用户价值)
1. 安装 `@xterm/addon-unicode11`(**先验证与 6.1.0-beta.304 兼容**;渲染层依赖 → devDependencies,遵守仓库依赖分类规则)+ `term.unicode.activeVersion = '11'`
2. 开 `term.options.rescaleOverlappingGlyphs = true`(仅 WebGL 有效,DOM 渲染器下无效需在文档/UI 标注)
3. `fontFamily` 支持独立 CJK 回退槽位(设置新增「中文字体」,拼接在字体栈尾)
### P1-3 字体下拉等宽徽标
- 渲染层 canvas `measureText` 检测字符步进一致性(采样 `i l 1 W m 0` 等字符宽度),列表加「等宽」徽标 + 默认等宽优先排序;同步修正「系统等宽字体」文案(现为误导)。
### P1-4 `MAX_LINE_LEN` 按行跳过(而非整段 run 跳过)
- 现状:一条 >4KB 的行让整个 text run(含其中的 `ERROR`)全部失去高亮,且无提示(`highlightEngine.ts:229-237`)。改为按行切分后只跳过超长行。`MAX_PER_RULE` 截断同理考虑提示(i18n 四语)。
### P1-5 字体预览补 CJK 混排样例(本轮实施:Qwen 任务 B 一并做)
- `TerminalPreview` 样例只有拉丁 `ls -la`。补中文文件名 + 权限位混排行,让用户选字体时即可暴露 CJK 错位。
---
## P2 功能演进(高亮的「匹配后动作」层)
### P2-1 引擎匹配事件出口(前置基建,性价比最高的新能力)
- 复用 `StatsSink` 注入模式:`applyHighlights` 接受可选事件 sink,命中时上报 `(ruleId, matchText, pane)`,默认路径零成本。这是解锁 P2-2/P2-3/P2-4 的钥匙。
- 难点:动作要跨到主进程/托盘(现有 `notifyUpdate` 只服务更新器);需按规则去重限流(忙碌日志每秒上千命中)。
### P2-2 secret 命中设防
- 依托 P2-1:secret 规则命中时可选「弹提醒 / 会话内打码 / 复制前警告」。现状 secret 与普通红字无区别,而它是 5 条 basic 规则之一。
### P2-3 用户自定义可点击链接规则(WezTerm hyperlink_rules 式)
- link provider 与「文本偏移→单元格坐标」映射已有(`TerminalView.tsx:1019-1047`,现仅 4 条写死 URL 正则)。接上「正则 + URL 模板(`$0/$1`)」即可,架构成本低。
### P2-4 关键词命中通知 / tab 标记(iTerm2 Triggers 子集)
- 依托 P2-1:匹配到指定模式时托盘通知(复用锁屏/托盘链路)、标签加红点。定位「跑长任务时人不在终端前」的场景。
---
## P3 打磨(按需排期)
| 项 | 内容 | 位置 |
|---|---|---|
| 归因诊断 | 编辑器显示草稿规则命中 N 处、被哪条规则抢走 | `HighlightTab.tsx` 预览扩展 |
| 规则管理 | 表格文本搜索、批量启停、命中 0 次提示 | `HighlightTab.tsx` |
| 主题色引用 | 规则颜色支持 `@green` 等主题 ANSI 引用 + per-rule「不跟随主题」 | `highlightColors.ts`、schema |
| 正则校验统一 | 主进程 `coerceRule` 补 `new RegExp` 校验(现编辑器/导入拦、主进程不拦、引擎静默跳过) | `settingsStore.ts:50-138` |
| 预设升级机制重建 | `LEGACY_BUILTIN_PATTERNS` 只覆盖 2 条旧 pattern,后续预设改动无法下发老用户 | `settingsStore.ts:145-163` |
| 16ms flush 建模 | `hl-split-smoke` 补应用层 16ms carry flush 的跨块场景(现为测试盲区) | `tests/hl-split-smoke.mjs` |
| 字体名双语 | 枚举优先 zh-cn 本地名,用户对不上 Consolas 等拉丁名;改双列显示 | `ipc.ts`、`SettingsTabs.tsx` |
| 主进程数字钳制 | fontSize/letterSpacing/lineHeight 仅 UI 钳制,主进程清洗放行任意值 | `settingsStore.ts:325-337` |
| SGR 跨转义匹配 | 被 SGR 打断的 token(如真彩色着色的 URL)匹配不到——低频,与 P0-2 同区域,可一并评估 | `highlightEngine.ts` tokenize |
---
## 执行批次
| 批次 | 内容 | 执行者 | 状态 |
|---|---|---|---|
| 本轮-1 | P0-1 perm 锚定修复(含审核轮 ACL 后缀修正) | Qwen3.8-27B 实施 + flash 审核 + 主模型定稿 | ✅ 完成 |
| 本轮-2 | P0-4 字体死代码删除 + P1-5 预览 CJK 样例 | Qwen3.8-27B 实施 + flash 审核 | ✅ 完成 |
| 本轮-审核 | 两个任务的代码审核(含独立探针复验) | flash(默认模型)子代理 | ✅ 完成(发现 1 处回退,已修;新增 P0-6) |
| 本轮-把关 | 测试全量回归 + 计划定稿 | 主模型 | ✅ 完成(`npm test` 29 测试全绿) |
| 第二批-波1 | P0-2 SGR 恢复 + P1-4 按行护栏(引擎)/ P0-3 统计聚合 + P0-6 死代码清理 | flash 实施 ×2 并行 + 主模型把关 | ✅ 完成(各 19/9/11 项新断言;pane key 用实例 UUID 规避 SSH 分屏同 session) |
| 第二批-波2 | P0-5 字体名引号包裹 / P1-1 按行匹配开关(m flag) | flash 实施 ×2 并行 + 主模型把关 | ✅ 完成(新增 tests/font-stack.mjs,测试数 29→30;i18n 四语 +2 key) |
| 第三批 | P1-2 CJK 三件套 + P1-3 等宽徽标 | flash 实施 ×2 并行(文件不相交、契约字段钉死)+ 主模型把关 | ✅ 完成(unicode11 装的是 0.10.0-beta.301,peer 精确接受 beta.304;`cjkFontFamily` 默认空=零行为变化;等宽检测 canvas 采样容差 0.01) |
| 远期 | P2-1 → P2-2/3/4 依次解锁 | flash 实施 ×2(串行两波)+ 主模型把关 | ✅ 完成(2026-10-17 第二轮) |
### P2 执行记录(2026-10-17 第二轮)
- **P2-1 引擎事件出口**:`applyHighlights` 第 5 参 `onMatch?: (ruleId, text) => void`,仅「真的画上屏」的 span 上报(被抢占/预算耗尽/超长行跳过都不报);`HighlightStream` 6 个 `applyHighlights` 调用点全透传;未传时零开销。测试 `[match events]` 段 7 断言(含 301 命中恰好 300 次上报的预算边界、跨切分只报一次)
- **P2-2 secret 命中提醒(产品取舍:应用内提醒,不打码)**:规则级 `notify?: boolean` 字段 + 编辑器「命中时提醒」开关;消费端 60s 冷却去重(per pane per rule);antd notification 提醒**只显示规则名、绝不渲染匹配原文**(secret 原文就是密钥);`notifyRules` 从 mode/profile 过滤后的实际规则集派生,被排除的规则不报警。**secret 预设默认 `notify: true`(仅新装;存量不静默升级**——把会产生弹窗的新行为塞进 refreshBuiltinRules 等于替用户做了「以后会被打断」的决定,用户可手动开)
- **P2-3 自定义可点击链接(WezTerm hyperlink_rules 式)**:`AppSettings.linkRules`(默认空)+ `src/shared/linkRules.ts` 纯模块(sanitize/formatLinkTemplate `$0`–`$9`/`compileLinkRules`/`mergeLinkSpans`)+ HighlightTab 底部编辑器(pattern/template/note/enabled,非法正则内联报错)+ link provider 叠加在内置 URL 之上(用户规则优先,热更走 ref 不重建终端)。**安全闸门**:`isSafeLinkTarget` 在生成 span 时就丢弃非 http/https 目标(`file://`/`javascript:` 连下划线都不出现),主进程 openExternal 协议白名单兜底。性能护栏:>4KB 行跳过用户规则、每规则每行 200 上限、零宽匹配强制前进。测试 `tests/link-rules.mjs` 86 断言(**测试数 30→31**)
- **P2-4 关键词命中通知/tab 标记**:通知子集已由 `notify` 字段覆盖(任何规则可开);**tab 红点标记搁置**——需 dockview 面板参数联动与面板生命周期管理,复杂度高且与通知重叠,待真实需求再评估
- 最终把关:`npm test` 31 个测试全绿、`verify-deps` 448 包一致、`npm run build` 通过、AGENTS.md 已同步(测试数 31、「可点击链接规则」新章)
- 未做 git 提交(用户要求)
### 进度记录(2026-10-17)
- P0 六项全部完成:P0-1 perm(含 ACL `+`/macOS `@` 后缀)、P0-2 SGR 恢复(`SgrState`,`MAX_RESTORE_LEN=256` 降级)、P0-3 统计按 pane 聚合、P0-4 死代码删除、P0-5 `quoteFontStack`(settings 存原样、消费点包裹)、P0-6 overviewRuler/as 断言清理(产品决定:不启用 scrollbar ruler,维持现状视觉)
- P1 五项全部完成:P1-1 `lineAnchor`(gm/gim + 编辑器开关 + `^$` 徽标)、P1-2 CJK 三件套(unicode11 0.10.0-beta.301 精确锁定 / `rescaleOverlappingGlyphs` / `cjkFontFamily` 槽位)、P1-3 等宽徽标 + 误导文案修正、P1-4 超长行按行降级、P1-5 预览中文混排样例
- 最终把关:`verify-deps` 448 包一致、`npm test` 30 个测试全绿、`npm run build` 通过、AGENTS.md 已同步(测试数、终端字体新章、高亮锚定/SGR/降级/统计四条铁律)
- 未做 git 提交(用户要求);工作区含全部改动
## 复查轮(2026-10-17 晚):完整 review 与修复
用户报「行高调到 0.95 后所有窗格崩溃」后做了一次全量复查,发现并修复以下问题(全部实测复现)。**行高只是冰山一角:核心缺陷是「UI 允许的值 / 配置能写的值 与 xterm 硬约束不一致,而主进程不做校验」。**
### 已修(P0)
| 问题 | 后果 | 修复 |
|---|---|---|
| `lineHeight < 1` | xterm 抛错 → 错误边界卸掉整个窗格;坏值落盘后每次启动都崩 | UI 下限 0.8→1 + 主进程钳制 + `healClampedFontSettings()` 回写自愈 |
| `cursorStyle` 非法值 | **更糟**:构造期静默吞错、热更期抛错,所以「重试」按钮永远无效,窗格死循环 | `CURSOR_STYLES` 白名单(shared 层单一来源) |
| `scrollback < 0` | 同上,热更抛错 | 纳入 `FONT_LIMITS` 钳制 100–100000 |
| `cursorInactiveStyle` / `rendererMode` 非法值 | 静默失效(失焦不画光标 / 误以为选了 DOM 却走 WebGL) | 同款白名单化 |
| **ReDoS** | pattern 无长度限制且预览每键跑一次:`(a+)+$` 配 30 字符实测卡 **8.2 秒**,25 字符翻倍。`MAX_LINE_LEN=4KB` 限的是长度不是回溯成本 | `MAX_PATTERN_LEN=500` + 编辑器 `maxLength` + `backtracksBadly()` 探测(4 组诱发输入实跑,超 50ms 即警告)。**根因未除**:正解需 Worker + 超时,列为待办 |
| **SGR reset 只认两种写法** | `ESC[00m`/`ESC[0;0m`/`ESC[0;32m`(Go/Java 日志库常写)被当作可累积序列**追加**,累积器逐行增长直到撞上限,之后所有 span 恢复永久失效 | 改为解析参数:全 0/空 = 全量复位;`0;…` 前缀先清空再记录 |
| **CSI 私有参数不识别** | `ESC[>4;1m`(modifyOtherKeys) / `ESC[<0;1;2M`(SGR 鼠标) / `ESC[=1;1$y`(DECRQM) 让整行成含裸 ESC 的文本,**该行之后所有关键词失去高亮**;vim/tmux 常发 | `ESCAPE_RE` 补 `<>=` 与中间字节区间 `[ -/]*` |
| `quoteFontStack` 不转义反斜杠 | 尾部反斜杠使 CSS 字符串不闭合,字体静默回落默认 | `replace(/[\\"]/g, '\\$&')` |
### 关键自我修正(两个判断失误,均已落成 AGENTS.md 铁律)
1. **回放闸门按字节数是错维度**:`MAX_REPLAY_COST=11` 会砍掉真彩色(13–19B)却放过索引色(11B)——同一个「单个前景色」语义待遇不同。改为按 **序列条数**(`MAX_REPLAY_SEQUENCES=3`)。
2. **恢复串不必每个 span 都重放**:只有「可见间隙」(≥3 个可见字符)和最后一个 span 之后才需要。改为按间隙插入后,密集 TUI 膨胀从 **4.30x 降到 3.05x**,长间隙着色仍正常(实测 12 处间隙正确恢复)。
### 待办(P1,本轮未做)
- **ReDoS 根治**:预览编译/执行挪进 Web Worker + 超时;可选引入 `re2`。现有长度上限只是止血
- `fontWeight`/`boldFontWeight` 越界被 xterm 静默改写(手改 json 才会遇到,纯体验问题)
- `resizePty` 的 `cols`/`rows` 完全信任渲染层(低危,靠自家 fit 逻辑兜底)
- 链接规则的导入/导出、profile 联动
## 约束提醒(对实施者)
- 高亮引擎改动必须过 `node tests/.hl-rules.cjs`(bundle 经 `node tests/build-bundles.cjs` 重建)与 `tests/hl-split-smoke`;改预设 pattern 必须同步 `LEGACY_BUILTIN_PATTERNS`,否则存量安装拿不到修复
- i18n 新文案四语齐备(zh-CN/zh-TW/en/ja,缺译回退简体)
- 渲染层新依赖一律 devDependencies;`@xterm/*` 版本必须与锁定的 6.1.0-beta.304 体系兼容,改依赖后必须真实 `npm install` 并跑 `node scripts/verify-deps.cjs`
- 改动若触碰 AGENTS.md 已记载的行为,同步更新 AGENTS.md
+11
View File
@@ -22,6 +22,7 @@
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "0.10.0-beta.301",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "6.1.0-beta.304",
"antd": "^6.6.2",
@@ -2898,6 +2899,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.301",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.301.tgz",
"integrity": "sha512-SS5BzfbvfFIikE4xZFAtHu6n0Kl5R1ZCPx41RL2WULLSE+HFo/WmYee53tMxNK/QUjpcQWepeVB7HebCPhHnig==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.304"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
+2 -1
View File
@@ -13,7 +13,7 @@
"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/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs && node tests/theme-contrast.mjs && node tests/css-theme-vars.mjs && node tests/ot-var-names.mjs",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs && node tests/theme-contrast.mjs && node tests/css-theme-vars.mjs && node tests/ot-var-names.mjs && node tests/font-stack.mjs && node tests/link-rules.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
@@ -32,6 +32,7 @@
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "0.10.0-beta.301",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "6.1.0-beta.304",
"antd": "^6.6.2",
+67
View File
@@ -5,6 +5,11 @@ import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
clampFontSetting,
FONT_LIMITS,
pickCursorInactiveStyle,
pickCursorStyle,
pickRendererMode,
isHighlightCategory,
isLockAutoDelay,
isUpdateCheckInterval,
@@ -18,6 +23,7 @@ import {
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { sanitizeProfiles } from '../shared/highlightProfiles'
import { sanitizeLinkRules } from '../shared/linkRules'
import { broadcast } from './broadcast'
import { writeJson } from './store'
import { applyGlobalShortcut } from './globalShortcuts'
@@ -91,6 +97,12 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
if (rule.caseInsensitive !== undefined && typeof rule.caseInsensitive !== 'boolean') {
warnings.push(`highlightRules[${index}].caseInsensitive ignored (not a boolean)`)
}
if (rule.lineAnchor !== undefined && typeof rule.lineAnchor !== 'boolean') {
warnings.push(`highlightRules[${index}].lineAnchor ignored (not a boolean)`)
}
if (rule.notify !== undefined && typeof rule.notify !== 'boolean') {
warnings.push(`highlightRules[${index}].notify ignored (not a boolean)`)
}
if (rule.basic !== undefined && typeof rule.basic !== 'boolean') {
warnings.push(`highlightRules[${index}].basic ignored (not a boolean)`)
}
@@ -131,6 +143,8 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
bands,
// `undefined` rather than `false` so the flag stays absent unless it is on.
caseInsensitive: rule.caseInsensitive === true ? true : undefined,
lineAnchor: rule.lineAnchor === true ? true : undefined,
notify: rule.notify === true ? true : undefined,
basic: rule.basic === true ? true : undefined,
category: isHighlightCategory(rule.category) ? rule.category : undefined,
note: typeof rule.note === 'string' ? rule.note : undefined
@@ -143,6 +157,7 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
* load-time refresh below may swap in the current preset for it.
*/
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
perm: ['^[-dlbcps][rwxstST-]{9}'],
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
}
@@ -314,6 +329,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
let highlightProfiles: unknown = []
let linkRules: unknown = []
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as {
@@ -321,6 +337,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
customThemes?: unknown
highlightRules?: unknown
highlightProfiles?: unknown
linkRules?: unknown
}
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
@@ -334,6 +351,26 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
}
}
terminal = merged as unknown as TerminalSettings
// A bad value in any of these is FATAL rather than cosmetic: xterm's
// `term.options.x = v` setter (the settings hot-update path) throws with
// no try/catch, and the throw unmounts the whole dockview pane. The cursor
// option is swallowed during construction while still blowing up on
// hot-update, so the pane's "retry" button re-crashes forever — a hand-
// edited or legacy value must therefore be repaired here, on load.
terminal.lineHeight = clampFontSetting('lineHeight', terminal.lineHeight, DEFAULT_SETTINGS.terminal.lineHeight)
terminal.fontSize = clampFontSetting('fontSize', terminal.fontSize, DEFAULT_SETTINGS.terminal.fontSize)
terminal.letterSpacing = clampFontSetting(
'letterSpacing',
terminal.letterSpacing,
DEFAULT_SETTINGS.terminal.letterSpacing
)
terminal.scrollback = clampFontSetting('scrollback', terminal.scrollback, DEFAULT_SETTINGS.terminal.scrollback)
terminal.cursorStyle = pickCursorStyle(terminal.cursorStyle, DEFAULT_SETTINGS.terminal.cursorStyle)
terminal.cursorInactiveStyle = pickCursorInactiveStyle(
terminal.cursorInactiveStyle,
DEFAULT_SETTINGS.terminal.cursorInactiveStyle
)
terminal.rendererMode = pickRendererMode(terminal.rendererMode, DEFAULT_SETTINGS.terminal.rendererMode)
}
if (Array.isArray(packageSettings.customThemes)) {
customThemes = packageSettings.customThemes
@@ -344,6 +381,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
if (packageSettings.highlightProfiles !== undefined) {
highlightProfiles = packageSettings.highlightProfiles
}
if (packageSettings.linkRules !== undefined) {
linkRules = packageSettings.linkRules
}
}
let system: unknown = { ...DEFAULT_SYSTEM }
@@ -393,6 +433,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
new Set(rules.map((rule) => rule.id)),
(message) => errors.push(message)
),
linkRules: sanitizeLinkRules(linkRules, (message) => errors.push(message)),
system: system as SystemSettings,
lock
},
@@ -516,6 +557,7 @@ function defaultSettings(): AppSettings {
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: copyDefaultRules(),
highlightProfiles: [],
linkRules: [],
system: { ...DEFAULT_SYSTEM },
lock: { ...DEFAULT_SETTINGS.lock }
}
@@ -544,12 +586,37 @@ export function loadSettings(): AppSettings {
}
const { settings, errors } = deepMerge(raw)
reportWarnings(errors)
healClampedFontSettings(raw, settings)
return settings
} catch (err) {
return discardUnparseableSettings(err)
}
}
/**
* Rewrite the file when the sanitizer had to clamp a fatal numeric font value.
* Without this the bad number stays on disk and is re-clamped on every start:
* harmless at runtime, but it leaves settings.json disagreeing with what the
* app actually uses, and any hand-edit that "works" on disk keeps looking valid.
* Best-effort — a failed heal must never stop the app from loading.
*/
function healClampedFontSettings(raw: unknown, healed: AppSettings): void {
try {
const stored = (raw as { terminal?: Record<string, unknown> }).terminal
if (stored === null || typeof stored !== 'object') return
const fatalKeys = [
...(Object.keys(FONT_LIMITS) as Array<keyof typeof FONT_LIMITS>),
'cursorStyle',
'cursorInactiveStyle',
'rendererMode'
] as Array<keyof TerminalSettings>
const changed = fatalKeys.some((key) => stored[key] !== undefined && stored[key] !== healed.terminal[key])
if (changed) writeJson(settingsPath(), healed)
} catch {
// Ignore: the in-memory settings are already correct.
}
}
let settingsQueue: Promise<unknown> = Promise.resolve()
/**
+3 -3
View File
@@ -3,7 +3,7 @@ import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import type { LockSettingsState } from '@shared/ipc'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { DEFAULT_SETTINGS, FONT_LIMITS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
@@ -154,8 +154,8 @@ if (typeof window !== 'undefined' && !window.api) {
;(window as unknown as { api: AppApi }).api = api
}
const MIN_FONT = 8
const MAX_FONT = 40
const MIN_FONT = FONT_LIMITS.fontSize.min
const MAX_FONT = FONT_LIMITS.fontSize.max
const RESET_FONT = 14
/**
+9 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { isLightTheme, semanticVars } from '@renderer/theme/semantic'
import { isLightTheme, semanticVars, type SemanticVars } from '@renderer/theme/semantic'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './monitor.css'
@@ -109,10 +109,10 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
/** Red/green/yellow threshold bar color for CPU usage. Takes the semantic
* var map so light themes get the darkened (WCAG-compliant) variants —
* canvas fills need actual color strings, CSS vars don't resolve there. */
function cpuColor(usage: number, vars: Record<string, string>): string {
if (usage > 80) return vars.dangerFg
if (usage > 60) return vars.warningFg
return vars.successFg
function cpuColor(usage: number, vars: SemanticVars): string {
if (usage > 80) return vars['--ot-danger-fg']
if (usage > 60) return vars['--ot-warning-fg']
return vars['--ot-success-fg']
}
/** Format a size in MB: show GB (1 decimal) once over 1024 MB. */
@@ -242,7 +242,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-sub mm-load">
{tFor(language, 'ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
</div>
<HistoryChart data={cpuHist} lineColor={vars.successFg} fillColor={withAlpha(vars.success, 0.15)} height={48} maxY={100} />
<HistoryChart data={cpuHist} lineColor={vars['--ot-success-fg']} fillColor={withAlpha(vars['--ot-success'], 0.15)} height={48} maxY={100} />
</section>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.mem')}>
@@ -254,7 +254,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</div>
<div className="mm-mem-row">
<div className="mm-mem-bar">
<Bar pct={memPct} color={vars.warningFg} />
<Bar pct={memPct} color={vars['--ot-warning-fg']} />
</div>
<span className="mm-mem-pct">{memPct.toFixed(0)}%</span>
</div>
@@ -303,8 +303,8 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</div>
</div>
<div className="mm-net-charts">
<HistoryChart data={rxHist} lineColor={vars.infoFg} fillColor={withAlpha(vars.info, 0.15)} height={40} />
<HistoryChart data={txHist} lineColor={vars.warningFg} fillColor={withAlpha(vars.warning, 0.15)} height={40} />
<HistoryChart data={rxHist} lineColor={vars['--ot-info-fg']} fillColor={withAlpha(vars['--ot-info'], 0.15)} height={40} />
<HistoryChart data={txHist} lineColor={vars['--ot-warning-fg']} fillColor={withAlpha(vars['--ot-warning'], 0.15)} height={40} />
</div>
</section>
+344 -7
View File
@@ -21,12 +21,19 @@ import {
type HighlightCategory,
type HighlightMode
} from '@shared/settings'
import type { HighlightRule } from '@shared/settings'
import type { HighlightRule, LinkRule } from '@shared/settings'
import {
EMPTY_LINK_RULES,
LINK_NOTE_MAX,
LINK_PATTERN_MAX,
LINK_TEMPLATE_MAX,
isSafeLinkTemplate
} from '@shared/linkRules'
import { useResolvedTheme, useSettingsStore } from './store'
import { HighlightImportExport } from './HighlightImportExport'
import { HighlightProfiles } from './HighlightProfiles'
import { ruleNote } from './ruleLabel'
import { compileRules, previewSpans } from '../terminal/highlightEngine'
import { MAX_PATTERN_LEN, compileRules, previewSpans } from '../terminal/highlightEngine'
import {
getHighlightStats,
resetHighlightStats,
@@ -69,15 +76,47 @@ function categoryRank(rule: HighlightRule): number {
return index < 0 ? HIGHLIGHT_CATEGORIES.length : index
}
function tryCompile(pattern: string): { ok: boolean; message?: string } {
function tryCompile(pattern: string): { ok: boolean; message?: string; slow?: boolean } {
if (pattern.trim() === '') return { ok: true }
let regex: RegExp
try {
// eslint-disable-next-line no-new
new RegExp(pattern)
return { ok: true }
regex = new RegExp(pattern)
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : t('settings.highlight.invalidRegex') }
}
return { ok: true, slow: backtracksBadly(regex) }
}
/**
* Cheap ReDoS probe for the editor.
*
* `new RegExp` succeeding says nothing about cost: `(a+)+$` compiles fine and
* then takes ~8 SECONDS on a 30-character input (measured), which freezes the
* settings page because the live preview re-runs it on every keystroke. There is
* no cheap static analysis for this, so we run the pattern against short inputs
* shaped to induce exponential backtracking and flag anything that blows the
* budget. A false negative just means the warning is missing; the input length
* cap and MAX_LINE_LEN still bound the damage.
*/
function backtracksBadly(regex: RegExp): boolean {
const shapes = [
'a'.repeat(24) + '!',
'a'.repeat(24) + ' a',
'ab'.repeat(16) + '!',
'1'.repeat(24) + 'x'
]
const probe = new RegExp(regex.source, regex.flags.includes('i') ? 'i' : '')
for (const input of shapes) {
const started = performance.now()
try {
probe.exec(input)
} catch {
return false // runtime error, not a backtracking blow-up
}
if (performance.now() - started > 50) return true
}
return false
}
interface RuleDraft {
@@ -88,6 +127,10 @@ interface RuleDraft {
bands?: { min: number; fg: string }[]
category?: HighlightCategory
caseInsensitive: boolean
/** anchor `^`/`$` per line (regex 'm' flag) */
lineAnchor: boolean
/** raise an in-app alert when this rule paints a span */
notify: boolean
note?: string
enabled: boolean
basic: boolean
@@ -163,6 +206,9 @@ export function HighlightTab(): React.JSX.Element {
const [statsTick, setStatsTick] = useState(0)
useEffect(() => subscribeHighlightStats(() => setStatsTick((n) => n + 1)), [])
// Already merged across every open pane (hits and ms summed per rule), so the
// hits/time columns and the reset button need no per-pane notion: one row per
// rule, and a reset zeroes all panes at once.
const ruleStats = useMemo(() => getHighlightStats().rules, [statsTick])
/** Only present while the stats switch is on. */
@@ -371,6 +417,258 @@ export function HighlightTab(): React.JSX.Element {
onClose={closeEditor}
ruleId={editor.id}
/>
<ClickableLinkRules />
</div>
)
}
/**
* User-defined clickable link rules ("pattern match → URL template → click").
*
* It lives at the bottom of the highlight tab because it is the same mental
* model as a highlight rule — a regex over terminal output — with a different
* payoff: instead of colouring the span, it turns the span into a link. The
* provider reads the rules through a ref, so saving here takes effect on the
* next hover without touching a single open session.
*/
function ClickableLinkRules(): React.JSX.Element {
const linkRules = useSettingsStore((s) => s.settings.linkRules ?? EMPTY_LINK_RULES)
const setLinkRules = useSettingsStore((s) => s.setLinkRules)
/** null = dialog closed; '' = creating; otherwise the rule being edited. */
const [editingId, setEditingId] = useState<string | null>(null)
const [pattern, setPattern] = useState('')
const [template, setTemplate] = useState('')
const [note, setNote] = useState('')
const [enabled, setEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const editing = linkRules.find((rule) => rule.id === editingId)
// seed the dialog each time it opens
useEffect(() => {
if (editingId === null) return
setPattern(editing?.pattern ?? '')
setTemplate(editing?.template ?? '')
setNote(editing?.note ?? '')
setEnabled(editing?.enabled !== false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingId])
const compile = tryCompile(pattern)
const templateOk = template.trim() === '' || isSafeLinkTemplate(template)
const valid =
pattern.trim() !== '' && compile.ok && template.trim() !== '' && templateOk
const patchRule = async (target: LinkRule, partial: Partial<LinkRule>): Promise<void> => {
// read at call time: the render-scoped array goes stale inside a React batch
const current = useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES
await setLinkRules(current.map((rule) => (rule.id === target.id ? { ...rule, ...partial } : rule)))
}
const handleSave = async (): Promise<void> => {
if (!valid) return
setSaving(true)
try {
const trimmedNote = note.trim()
const current = useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES
if (editing !== undefined) {
const next: LinkRule = {
...editing,
pattern: pattern.trim(),
template: template.trim(),
// explicit override: unchecking must clear an `enabled: true` the
// edited rule may already carry
enabled,
...(trimmedNote ? { note: trimmedNote } : { note: undefined })
}
await setLinkRules(current.map((rule) => (rule.id === next.id ? next : rule)))
} else {
const fresh: LinkRule = {
id: crypto.randomUUID(),
pattern: pattern.trim(),
template: template.trim(),
enabled,
...(trimmedNote ? { note: trimmedNote } : {})
}
await setLinkRules([...current, fresh])
}
setEditingId(null)
} finally {
setSaving(false)
}
}
const columns: ColumnsType<LinkRule> = [
{
title: t('settings.highlight.linkEnabled'),
dataIndex: 'enabled',
width: 64,
align: 'center',
render: (value: boolean | undefined, record) => (
<Switch
size="small"
checked={value !== false}
onChange={(checked) => void patchRule(record, { enabled: checked })}
/>
)
},
{
title: t('settings.highlight.linkPattern'),
dataIndex: 'pattern',
ellipsis: true,
render: (value: string, record) => {
const { ok, message } = tryCompile(value)
return (
<div className="hl-pattern-cell">
<Tooltip title={value}>
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{value}</span>
</Tooltip>
{!ok && (
<Tooltip title={message}>
<span className="hl-warn-icon">⚠</span>
</Tooltip>
)}
{record.note != null && record.note !== '' && (
<span className="hl-cell-note">{record.note}</span>
)}
</div>
)
}
},
{
title: t('settings.highlight.linkTemplate'),
dataIndex: 'template',
ellipsis: true,
render: (value: string) => (
<Tooltip title={value}>
<span
className={isSafeLinkTemplate(value) ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}
>
{value}
</span>
</Tooltip>
)
},
{
title: t('settings.highlight.actions'),
key: 'action',
width: 120,
align: 'center',
render: (_value, record) => (
<div className="hl-actions">
<Button size="small" onClick={() => setEditingId(record.id)}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('settings.highlight.linkDeleteTitle')}
description={t('settings.deleteConfirmDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() =>
void setLinkRules(
(useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES).filter(
(rule) => rule.id !== record.id
)
)
}
>
<Button size="small" danger>
{t('common.delete')}
</Button>
</Popconfirm>
</div>
)
}
]
return (
<div className="hl-profiles">
<div className="hl-profiles-head">
<span className="hl-editor-sub-label">{t('settings.highlight.linksTitle')}</span>
<Button size="small" onClick={() => setEditingId('')}>
{t('settings.highlight.linkAdd')}
</Button>
</div>
<span className="hl-editor-hint">{t('settings.highlight.linksHint')}</span>
<Table
size="small"
rowKey="id"
columns={columns}
dataSource={linkRules}
pagination={false}
locale={{ emptyText: t('settings.highlight.linkEmpty') }}
/>
<Modal
open={editingId !== null}
onCancel={() => setEditingId(null)}
onOk={() => void handleSave()}
okText={t('common.save')}
cancelText={t('common.cancel')}
confirmLoading={saving}
destroyOnHidden
width={560}
title={
editing ? t('settings.highlight.linkEditTitle') : t('settings.highlight.linkCreateTitle')
}
okButtonProps={{ disabled: !valid }}
>
<div className="hl-editor">
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.linkPattern')}</span>
<div className="hl-editor-control">
<Input.TextArea
value={pattern}
onChange={(e) => setPattern(e.target.value)}
placeholder={t('settings.highlight.patternPlaceholder')}
autoSize={{ minRows: 1, maxRows: 4 }}
maxLength={LINK_PATTERN_MAX}
className={compile.ok ? undefined : 'hl-editor-input-bad'}
/>
{!compile.ok && compile.message != null && (
<div className="hl-editor-error">{compile.message}</div>
)}
{compile.ok && compile.slow && (
<div className="hl-editor-error">{t('settings.highlight.patternSlow')}</div>
)}
<span className="hl-editor-hint">{t('settings.highlight.linkPatternHint')}</span>
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.linkTemplate')}</span>
<div className="hl-editor-control">
<Input
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="https://example.com/item/$1"
maxLength={LINK_TEMPLATE_MAX}
className={templateOk ? undefined : 'hl-editor-input-bad'}
/>
{!templateOk && (
<div className="hl-editor-error">
{t('settings.highlight.linkInvalidTemplate')}
</div>
)}
<span className="hl-editor-hint">{t('settings.highlight.linkTemplateHint')}</span>
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.note')}</span>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t('settings.highlight.notePlaceholder')}
maxLength={LINK_NOTE_MAX}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.linkEnabled')}</span>
<Switch checked={enabled} onChange={setEnabled} />
</div>
</div>
</Modal>
</div>
)
}
@@ -379,6 +677,11 @@ function PatternCell({ rule }: { rule: HighlightRule }): React.JSX.Element {
const { ok, message } = tryCompile(rule.pattern)
return (
<div className="hl-pattern-cell">
{rule.lineAnchor === true && (
<Tooltip title={t('settings.highlight.lineAnchor')}>
<span className="hl-case-badge">{'^$'}</span>
</Tooltip>
)}
{rule.caseInsensitive === true && (
<Tooltip title={t('settings.highlight.caseInsensitive')}>
<span className="hl-case-badge">Aa</span>
@@ -426,6 +729,8 @@ function HighlightEditor({
fg: '#3fb950',
bg: undefined,
caseInsensitive: false,
lineAnchor: false,
notify: false,
note: undefined,
enabled: true,
basic: false
@@ -447,7 +752,8 @@ function HighlightEditor({
priority: draft.priority,
color: { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) },
...(draft.bands && draft.bands.length > 0 ? { bands: draft.bands } : {}),
...(draft.caseInsensitive ? { caseInsensitive: true } : {})
...(draft.caseInsensitive ? { caseInsensitive: true } : {}),
...(draft.lineAnchor ? { lineAnchor: true } : {})
}
const active = [...highlightRules.filter((rule) => rule.id !== ruleId), draftRule]
// The preview has to show the colours the terminal will use, so the theme
@@ -460,6 +766,7 @@ function HighlightEditor({
draft.bg,
draft.bands,
draft.caseInsensitive,
draft.lineAnchor,
showBg,
highlightRules,
ruleId,
@@ -490,10 +797,12 @@ function HighlightEditor({
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const lineAnchor = editing ? editing.lineAnchor === true : false
const notify = editing ? editing.notify === 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, basic })
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, lineAnchor, notify, note, enabled, basic })
setShowBg(bg != null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -535,6 +844,8 @@ function HighlightEditor({
try {
const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) }
const caseInsensitive = draft.caseInsensitive ? true : undefined
const lineAnchor = draft.lineAnchor ? true : undefined
const notify = draft.notify ? true : undefined
const bands = draft.bands && draft.bands.length > 0 ? draft.bands : undefined
if (isCreate) {
const rule: HighlightRule = {
@@ -546,6 +857,8 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
lineAnchor,
notify,
...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined
}
@@ -563,6 +876,8 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
lineAnchor,
notify,
// explicit override (not a conditional spread): unchecking must clear
// a basic flag the edited rule may already carry
basic: draft.basic ? true : undefined,
@@ -598,11 +913,15 @@ function HighlightEditor({
onChange={(e) => patch({ pattern: e.target.value })}
placeholder={t('settings.highlight.patternPlaceholder')}
autoSize={{ minRows: 1, maxRows: 4 }}
maxLength={MAX_PATTERN_LEN}
className={compile.ok ? undefined : 'hl-editor-input-bad'}
/>
{!compile.ok && compile.message != null && (
<div className="hl-editor-error">{compile.message}</div>
)}
{compile.ok && compile.slow && (
<div className="hl-editor-error">{t('settings.highlight.patternSlow')}</div>
)}
<div className="hl-editor-inline">
<Switch
size="small"
@@ -612,6 +931,24 @@ function HighlightEditor({
<span className="hl-editor-sub-label">{t('settings.highlight.caseInsensitive')}</span>
<span className="hl-editor-hint">{t('settings.highlight.caseInsensitiveHint')}</span>
</div>
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.lineAnchor}
onChange={(c) => patch({ lineAnchor: c })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.lineAnchor')}</span>
<span className="hl-editor-hint">{t('settings.highlight.lineAnchorHint')}</span>
</div>
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.notify}
onChange={(c) => patch({ notify: c })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.notify')}</span>
<span className="hl-editor-hint">{t('settings.highlight.notifyHint')}</span>
</div>
</div>
</div>
+154 -20
View File
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
import { UPDATE_CHECK_INTERVALS, type UpdateCheckInterval } from '@shared/settings'
import { FONT_LIMITS, UPDATE_CHECK_INTERVALS, type UpdateCheckInterval } from '@shared/settings'
import type { ThemeColors } from '@shared/theme'
import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n'
import { isReservedAccelerator } from '@shared/reservedAccelerators'
import { quoteFontStack } from '@renderer/terminal/fontStack'
import { useSettingsStore, useResolvedTheme } from './store'
import {
DEFAULT_FONT_STACK,
@@ -15,10 +16,97 @@ import {
weightOptions
} from './fields'
/**
* Glyphs that expose a proportional face: a narrow cluster and a wide cluster.
* Every one of them has to advance by the same width for the face to be usable
* in a terminal grid.
*/
const MONO_SAMPLE = 'i1l.,:;|Wm0@'
/**
* Shared 2D context for the monospace probe. `measureText` is cheap, creating a
* canvas is not, and the probe runs once per system font on every mount of the
* font pane (the font list is only fetched on mount).
*/
let probeCtx: CanvasRenderingContext2D | null | undefined
function probeContext(): CanvasRenderingContext2D | null {
if (probeCtx === undefined) {
probeCtx = document.createElement('canvas').getContext('2d')
}
return probeCtx
}
/**
* True when every sampled glyph advances by the same, non-zero width.
*
* Canvas silently falls back to a default face for a family it cannot resolve,
* and that fallback is proportional in practice, so a zero/failed measurement
* only ever costs a missing badge — never a wrong one.
*/
function isMonospaceFont(name: string): boolean {
const ctx = probeContext()
const stack = quoteFontStack(name)
if (!ctx || !stack) return false
ctx.font = `16px ${stack}`
let advance = 0
for (const ch of MONO_SAMPLE) {
const w = ctx.measureText(ch).width
if (!(w > 0)) return false
if (advance === 0) advance = w
else if (Math.abs(w - advance) > 0.01) return false
}
return advance > 0
}
/** Font dropdown row: the name drawn in its own face plus a monospace chip. */
function FontOptionLabel({ name, mono }: { name: string; mono: boolean }): React.JSX.Element {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, maxWidth: '100%' }}>
<span
style={{
fontFamily: quoteFontStack(name),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{name}
</span>
{mono && (
<span
title={t('settings.font.monoBadge')}
style={{
flex: '0 0 auto',
padding: '1px 4px',
borderRadius: 3,
fontSize: 10,
lineHeight: '14px',
background: 'var(--ot-focus-ring, rgba(59, 142, 234, 0.2))',
color: 'var(--ot-info, #58a6ff)'
}}
>
{t('settings.font.monoBadge')}
</span>
)}
</span>
)
}
/** Substring match on the option's family name, so a filter never touches the badge. */
function filterFontOption(input: string, option?: { value?: unknown; searchText?: string }): boolean {
const needle = input.trim().toLowerCase()
if (!needle) return true
const text = option?.searchText ?? (typeof option?.value === 'string' ? option.value : '')
return text.toLowerCase().includes(needle)
}
export function FontSettingsTab(): React.JSX.Element {
const settings = useSettingsStore((s) => s.settings)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
const theme = useResolvedTheme()
const fontFamily = settings.terminal.fontFamily
const cjkFontFamily = settings.terminal.cjkFontFamily ?? ''
const [fonts, setFonts] = useState<string[]>([])
@@ -32,23 +120,30 @@ export function FontSettingsTab(): React.JSX.Element {
return []
})
/**
* Dropdown entries, monospace first. The probe runs once per font list (the
* list only changes when `listFonts()` resolves) and the sort is by the
* original index within each bucket, so equal fonts keep the OS order.
*/
const fontOptions = useMemo(() => {
const seen = new Set<string>()
const items: Array<{ value: string; label: ReactNode }> = []
const mono: Array<{ value: string; label: ReactNode }> = []
const other: Array<{ value: string; label: ReactNode }> = []
for (const f of fonts) {
if (seen.has(f)) continue
seen.add(f)
items.push({ value: f, label: <span style={{ fontFamily: f }}>{f}</span> })
const isMono = isMonospaceFont(f)
const item = { value: f, label: <FontOptionLabel name={f} mono={isMono} /> }
if (isMono) mono.push(item)
else other.push(item)
}
return items
return [...mono, ...other]
}, [fonts])
/** map the persisted family to a recognized option value (default / system font / verbatim) */
const selectedFont = (): string => {
const cur = settings.terminal.fontFamily
if (cur === DEFAULT_FONT_STACK) return 'default'
if (fonts.includes(cur)) return cur
return cur
if (fontFamily === DEFAULT_FONT_STACK) return 'default'
return fontFamily
}
const handleFontChange = (value: string): void => {
@@ -57,6 +152,18 @@ export function FontSettingsTab(): React.JSX.Element {
})
}
/** CJK fallback slot: '' means "append nothing"; otherwise a bare family name. */
const cjkOptions = [
{ value: '', searchText: t('settings.font.cjkFamilyNone'), label: t('settings.font.cjkFamilyNone') },
...fontOptions
]
const previewFontStack = useMemo(() => {
// the store holds the resolved stack, but tolerate a 'default'/empty value
const base = !fontFamily || fontFamily === 'default' ? DEFAULT_FONT_STACK : fontFamily
return quoteFontStack([base, cjkFontFamily].filter(Boolean).join(', '))
}, [fontFamily, cjkFontFamily])
return (
<div className="settings-pane">
<SettingRow
@@ -72,14 +179,27 @@ export function FontSettingsTab(): React.JSX.Element {
options={[
{
value: 'default',
searchText: t('settings.font.default'),
label: <span style={{ fontFamily: DEFAULT_FONT_STACK }}>{t('settings.font.default')}</span>
},
...fontOptions
]}
filterOption={(input, option) =>
typeof option?.value === 'string' &&
option.value.toLowerCase().includes(input.toLowerCase())
}
filterOption={filterFontOption}
/>
}
/>
<SettingRow
label={t('settings.font.cjkFamily')}
desc={t('settings.font.cjkFamilyExtra')}
control={
<Select
className="settings-select"
showSearch
placeholder={t('settings.font.placeholder')}
value={cjkFontFamily}
onChange={(v: string) => void updateTerminal({ cjkFontFamily: v })}
options={cjkOptions}
filterOption={filterFontOption}
/>
}
/>
@@ -89,8 +209,8 @@ export function FontSettingsTab(): React.JSX.Element {
control={
<InputNumber
className="settings-input-number"
min={8}
max={40}
min={FONT_LIMITS.fontSize.min}
max={FONT_LIMITS.fontSize.max}
value={settings.terminal.fontSize}
onChange={(v) => {
if (v !== null) void updateTerminal({ fontSize: v })
@@ -132,8 +252,8 @@ export function FontSettingsTab(): React.JSX.Element {
control={
<InputNumber
className="settings-input-number"
min={0}
max={10}
min={FONT_LIMITS.letterSpacing.min}
max={FONT_LIMITS.letterSpacing.max}
step={0.5}
value={settings.terminal.letterSpacing}
onChange={(v) => {
@@ -148,8 +268,8 @@ export function FontSettingsTab(): React.JSX.Element {
control={
<InputNumber
className="settings-input-number"
min={0.8}
max={2}
min={FONT_LIMITS.lineHeight.min}
max={FONT_LIMITS.lineHeight.max}
step={0.05}
value={settings.terminal.lineHeight}
onChange={(v) => {
@@ -160,7 +280,7 @@ export function FontSettingsTab(): React.JSX.Element {
/>
<TerminalPreview
fontFamily={settings.terminal.fontFamily}
fontFamily={previewFontStack}
fontSize={settings.terminal.fontSize}
fontWeight={settings.terminal.fontWeight}
lineHeight={settings.terminal.lineHeight}
@@ -655,6 +775,20 @@ function TerminalPreview({
<span style={{ color: colors.brightBlack }}>1 bill users 42 Sep 5 09:41</span>{' '}
<span style={{ color: colors.green }}>index.ts</span>
</span>,
<span>
<span style={{ color: colors.red }}>-rw-r--r--</span>{' '}
<span style={{ color: colors.brightBlack }}>1 bill users 1024 10月 17 09:30</span>{' '}
<span style={{ color: colors.brightBlue }}>会议记录.md</span>
</span>,
<span>
<span style={{ color: colors.green }}>✓ </span>
<span style={{ color: colors.white }}>构建完成,耗时 3.2s</span>
</span>,
<span>
<span style={{ color: colors.brightBlack }}>正在编译 </span>
<span style={{ color: colors.yellow }}>src/组件/终端视图.tsx</span>
<span style={{ color: colors.white }}> …</span>
</span>,
<span>
<span style={{ color: colors.brightCyan }}>
bill@desktop:~$ _
@@ -662,7 +796,7 @@ function TerminalPreview({
</span>
]
const gridStyle: React.CSSProperties = {
fontFamily,
fontFamily: quoteFontStack(fontFamily),
fontSize,
fontWeight,
lineHeight,
+9
View File
@@ -19,6 +19,8 @@ export interface SettingsState {
setHighlightRules: (rules: AppSettings['highlightRules']) => Promise<void>
/** replace highlightProfiles array and persist */
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
/** replace linkRules array and persist */
setLinkRules: (rules: NonNullable<AppSettings['linkRules']>) => Promise<void>
/** shallow-merge into settings.system and persist */
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
/** shallow-merge into settings.lock and persist */
@@ -65,6 +67,7 @@ async function persist(
if (written.highlightProfiles !== prev.highlightProfiles) {
patch.highlightProfiles = written.highlightProfiles
}
if (written.linkRules !== prev.linkRules) patch.linkRules = written.linkRules
const terminal = diffGroup(prev.terminal, written.terminal)
// Main merges each group one level deep, so a group holding only the
// changed sub-keys is a valid patch payload even though the contract types
@@ -133,6 +136,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
await persist(get, set, next, prev, 'setHighlightProfiles')
},
setLinkRules: async (rules) => {
const prev = get().settings
const next: AppSettings = { ...prev, linkRules: rules }
await persist(get, set, next, prev, 'setLinkRules')
},
updateSystem: async (partial) => {
const prev = get().settings
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
+205 -24
View File
@@ -4,24 +4,27 @@ import type { ILink, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { App as AntdApp, Checkbox, Modal } from 'antd'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import { isLightTheme } from '@renderer/theme/semantic'
import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { highlightModeOf, type HighlightRule, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles'
import { compileLinkRules, EMPTY_LINK_RULES, findRuleLinks, mergeLinkSpans, type CompiledLinkRule } from '@shared/linkRules'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { useShallow } from 'zustand/react/shallow'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats'
import { compileRules, HighlightStream, type MatchListener, type RuleStat } from './highlightEngine'
import { publishHighlightStats, unregisterHighlightStats } from './highlightStats'
import { applyThemeColors } from '@renderer/theme/highlightColors'
import { findUrls } from './urlLinks'
import { WriteBatcher } from './writeBatcher'
import { refitDelay } from './fitThrottle'
import { quoteFontStack } from './fontStack'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -40,6 +43,23 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
return n
}
/**
* Append the CJK fallback family to the tail of the configured stack.
*
* The primary faces are chosen for code (narrow, boxed ASCII); the wide CJK
* cells — and the emoji/box-drawing glyphs a CJK font tends to carry — fall
* through to whatever the OS picks last, which is exactly where a terminal
* ends up with double-width cells rendered at a different advance and columns
* drifting out of alignment. Pinning a CJK family at the end makes those cells
* come from one known face. Appending happens before quoting so the stack is
* split once, on the user's text.
*/
function appendCjk(family: string, cjk: string | undefined): string {
const extra = (cjk ?? '').trim()
if (!extra) return family
return `${family}, ${extra}`
}
/**
* Paste-risk heuristic, split by shape rather than by raw length:
* - a paste ending in a newline always confirms — the trailing \r is the
@@ -140,6 +160,27 @@ function searchResultText(result: SearchResult, language: Language): string {
/** M5: cap for the floating completion popup height in px. */
const SUGGEST_POPUP_H = 200
/**
* Minimum gap between two "rule hit" alerts for the same rule in one pane.
* A leaking-secret rule matches every token of a repeating command's output,
* so without this one `kubectl get secret` would stack a hundred cards.
*/
const NOTIFY_COOLDOWN_MS = 60_000
/**
* Display name of a rule for the alert body: the translated note for a built-in,
* the user's own note for a rule they wrote, and the pattern as the last resort
* (a rule with neither is still identifiable that way). The matched text is
* never part of this — see `notifyMatch`.
*/
function notifyRuleName(rule: HighlightRule, language: Language): string {
const key = `settings.highlight.builtin.${rule.id}`
const translated = tFor(language, key)
if (translated !== key) return translated
const note = rule.note?.trim()
return note !== undefined && note !== '' ? note : rule.pattern
}
/** M5: estimated popup height for a given number of suggestion rows. */
function estimatePopupHeight(itemCount: number): number {
// item ≈ 21px, hint row ≈ 18px, popup v-padding + gaps ≈ 10px.
@@ -289,6 +330,11 @@ export function TerminalView({
isSsh = false,
connectionId
}: TerminalViewProps): React.JSX.Element {
// antd's context-bound notification instance: it inherits the ConfigProvider
// theme, and its portal lives outside `#root` — which is exactly what the lock
// screen's MutationObserver marks inert, so a hit alert cannot steal focus
// from the password field.
const { notification } = AntdApp.useApp()
// Field-level subscriptions. A single `s.settings` subscription made every
// pane re-write every xterm option and refit on ANY settings write — the
// whole-`settings` object identity changes even when an unrelated group
@@ -299,6 +345,7 @@ export function TerminalView({
useShallow((s) => ({
fontSize: s.settings.terminal.fontSize,
fontFamily: s.settings.terminal.fontFamily,
cjkFontFamily: s.settings.terminal.cjkFontFamily,
fontWeight: s.settings.terminal.fontWeight,
boldFontWeight: s.settings.terminal.boldFontWeight,
letterSpacing: s.settings.terminal.letterSpacing,
@@ -336,6 +383,9 @@ export function TerminalView({
stats: s.settings.terminal.highlightStats
}))
)
// Clickable link rules: one field, its own subscription, so an unrelated
// settings write cannot recompile the provider's rule set.
const linkRules = useSettingsStore((s) => s.settings.linkRules ?? EMPTY_LINK_RULES)
const toolbar = useSettingsStore(
useShallow((s) => ({
showRecButton: s.settings.terminal.showRecButton,
@@ -420,26 +470,115 @@ export function TerminalView({
}
}, [connectionId, highlightOpts.perHost])
const compiledRules = useMemo(() => {
/**
* The rules this pane actually runs: mode filter, then the bound host profile.
* Single source of truth — both the compiled set and the "notify on hit" set
* are derived from it, so a rule silenced by `basic` mode or left out by the
* host profile can never alert either.
*/
const activeRules = useMemo<HighlightRule[]>(() => {
if (highlightOpts.mode === 'off') return []
const active =
highlightOpts.mode === 'basic'
? highlightOpts.rules.filter((rule) => rule.basic === true)
: highlightOpts.rules
const perHost = highlightOpts.perHost
return highlightOpts.perHost
? rulesForProfile(active, boundProfileId, highlightOpts.profiles)
: active
}, [highlightOpts, boundProfileId])
const compiledRules = useMemo(() => {
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
// colour comes from the theme the terminal is actually drawn with.
return compileRules(
highlightOpts.themeColors ? applyThemeColors(perHost, highlightTheme) : perHost
highlightOpts.themeColors ? applyThemeColors(activeRules, highlightTheme) : activeRules
)
}, [highlightOpts, boundProfileId, highlightTheme])
}, [activeRules, highlightOpts.themeColors, highlightTheme])
// Rules flagged "notify on hit", resolved against the active set: the match
// event carries only a rule id, so this map is what turns it into a name.
// Rebuilt with the rule set (not per chunk) — the write path just does a Map
// lookup.
const notifyRules = useMemo(() => {
const map = new Map<string, HighlightRule>()
for (const rule of activeRules) {
if (rule.notify === true) map.set(rule.id, rule)
}
return map
}, [activeRules])
const notifyRulesRef = useRef(notifyRules)
useEffect(() => {
notifyRulesRef.current = notifyRules
}, [notifyRules])
// User clickable-link rules, compiled once per rules change and read through
// a ref by the link provider. The provider is registered with the terminal
// (once per session bind), so re-pointing the ref is the hot update — a
// rebuilt provider would mean tearing down and recreating the terminal just
// to add a URL pattern.
const compiledLinkRules = useMemo<CompiledLinkRule[]>(
() => compileLinkRules(linkRules),
[linkRules]
)
const linkRulesRef = useRef(compiledLinkRules)
useEffect(() => {
linkRulesRef.current = compiledLinkRules
}, [compiledLinkRules])
/**
* Match-event consumer for the rules flagged `notify`. Per-pane cooldown:
* `secret` fires on every token in a looping command's output, and one alert
* per rule per minute is the difference between a warning and a machine gun.
* The map is per pane instance (not global) so two panes each get their own
* budget — a pane is what the user is actually looking at.
*
* The notification body names the rule and nothing else: the matched text is
* the secret in the very case this feature exists for, so it must never reach
* a rendered string (or a log).
*
* Runs inside the highlight path, so it is wrapped in a try/catch: a broken
* notification surface must never take the terminal's write path down with it.
*/
const [notifyLastAt] = useState(() => new Map<string, number>())
const notifyMatch = useCallback<MatchListener>(
(ruleId) => {
const rule = notifyRulesRef.current.get(ruleId)
if (rule === undefined) return
const at = Date.now()
const last = notifyLastAt.get(ruleId)
if (last !== undefined && at - last < NOTIFY_COOLDOWN_MS) return
notifyLastAt.set(ruleId, at)
try {
// Read the language at fire time: this callback outlives any one render,
// and a notification built from a captured value would keep the language
// the pane was mounted with.
const language = useSettingsStore.getState().settings.system.language ?? DEFAULT_LANGUAGE
notification.warning({
// One slot per rule: a repeat replaces the previous card instead of
// stacking a second one on top of it.
key: `hl-notify-${ruleId}`,
title: tFor(language, 'settings.highlight.notifyTitle'),
description: tFor(language, 'settings.highlight.notifyBody', {
name: notifyRuleName(rule, language)
}),
duration: 10
})
} catch {
/* a missing notification surface must not break the write path */
}
},
[notification, notifyLastAt]
)
// Optional per-rule stats (settings → highlight → "count hits and time").
// Attached and detached here, so toggling the switch never rebuilds the
// terminal, and published on a timer — a busy terminal would otherwise
// re-render the settings page once per chunk.
// re-render the settings page once per chunk. The publish registers this
// pane's map under a per-instance id, so the settings page sees every pane's
// numbers added up instead of whichever pane happened to publish last. The id
// is NOT the session id: an SSH split shows one session in two panes, and a
// shared key would let the second pane overwrite the first one's counters.
const [statsPaneId] = useState(() => crypto.randomUUID())
const statsRef = useRef<Map<string, RuleStat> | null>(null)
useEffect(() => {
const sink = highlightOpts.stats
@@ -447,16 +586,32 @@ export function TerminalView({
: null
statsRef.current = sink
streamRef.current?.setStats(sink ?? undefined)
if (sink === null) return undefined
publishHighlightStats(new Map(sink))
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
if (sink === null) {
// Switching the feature off unmounts the sink but not the pane, so its
// frozen counters have to leave the registry too — otherwise they would
// sit in the merged totals while other panes keep publishing.
unregisterHighlightStats(statsPaneId)
return undefined
}
publishHighlightStats(statsPaneId, sink)
const timer = window.setInterval(() => publishHighlightStats(statsPaneId, sink), 1000)
return () => window.clearInterval(timer)
}, [highlightOpts.stats])
}, [highlightOpts.stats, statsPaneId])
// Deregister on unmount only. A session rebind tears the terminal down
// through the layout effect's cleanup below, but the pane keeps living — and
// React runs all cleanups before the next setups, so unregistering there
// would leave a rebound pane missing from the totals until its next publish.
useEffect(() => () => unregisterHighlightStats(statsPaneId), [statsPaneId])
// 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])
// Same for the match outlet: it is stable (useCallback with fixed deps), but
// re-attaching keeps a stream created before this effect ran in sync.
useEffect(() => {
streamRef.current?.setOnMatch(notifyMatch)
}, [notifyMatch])
// M5: load completion candidates once per session (history + library cached).
const ensureCompletionCache = useCallback((): void => {
@@ -851,11 +1006,17 @@ export function TerminalView({
const lightTheme = isLightTheme(themeColors.background)
const term = new Terminal({
fontSize: fontOpts.fontSize,
fontFamily: fontOpts.fontFamily,
fontFamily: quoteFontStack(appendCjk(fontOpts.fontFamily, fontOpts.cjkFontFamily)),
fontWeight: toFontWeight(fontOpts.fontWeight),
fontWeightBold: toFontWeight(fontOpts.boldFontWeight),
letterSpacing: fontOpts.letterSpacing,
lineHeight: fontOpts.lineHeight,
// Squeeze glyphs that overflow a single cell back into their cell. The
// default font sets draw box-drawing/block characters, and some CJK
// faces, wider than the advance xterm assumes, which paints over the
// neighbouring cell and breaks column alignment across the whole line.
// WebGL renderer only (the DOM renderer lays out per cell by the browser).
rescaleOverlappingGlyphs: true,
scrollback: fontOpts.scrollback,
cursorBlink: fontOpts.cursorBlink,
cursorStyle: fontOpts.cursorStyle,
@@ -863,7 +1024,6 @@ export function TerminalView({
theme: withChromeColors(themeColors as ITheme, bgOpts.image !== ''),
allowProposedApi: true,
allowTransparency: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
// Light themes ship WCAG-compliant palettes, but apps still emit
// dark-tuned bright/bold colors; bold must NOT jump to the bright set.
drawBoldTextInBrightColors: !lightTheme,
@@ -875,16 +1035,25 @@ export function TerminalView({
// theme colors. (Spelled minimumContrastRatio — minContrastRatio is
// silently ignored.)
minimumContrastRatio: bgOpts.image || lightTheme ? 4.5 : 1,
automaticFontFallback: true,
convertEol: false,
windowsPty: { backend: 'conpty', buildNumber: 0 }
} as Terminal['options'] & object)
})
term.attachCustomKeyEventHandler(onTerminalKey)
if (!hostRef.current) return
term.open(hostRef.current)
// Unicode 11 width tables. xterm ships Unicode 6 by default, where emoji
// and a chunk of the CJK/emoji presentation ranges are measured as narrow:
// a shell that emits those characters leaves xterm's column count out of
// step with the app's own idea of the line width, so everything after them
// on that line misaligns. The addon registers the table and belongs to this
// terminal instance, so it is disposed with `term`.
const unicode11 = new Unicode11Addon()
term.loadAddon(unicode11)
term.unicode.activeVersion = '11'
const fit = new FitAddon()
term.loadAddon(fit)
fitAddonRef.current = fit
@@ -958,7 +1127,7 @@ export function TerminalView({
// unsubscribe functions — keep the two shapes apart or cleanup throws.
// A fresh HighlightStream is created per session so carry data never leaks
// across a sessionId rebind (e.g. template apply).
const stream = new HighlightStream(compiledRules)
const stream = new HighlightStream(compiledRules, notifyMatch)
streamRef.current = stream
// A session created while stats are on keeps collecting into the same map.
stream.setStats(statsRef.current ?? undefined)
@@ -994,6 +1163,15 @@ export function TerminalView({
// the pointer cursor + underline advertise it on hover. A plain click stays
// with the terminal (cursor placement / selection), so a link can never
// open by accident while selecting text.
//
// Two sources feed one range list: the built-in URL heuristics
// (`findUrls`) and the user's own clickable-link rules. The user rules win
// every overlap — a rule is a deliberate statement about this output, while
// the built-ins are guesses — and `findRuleLinks` has already dropped any
// target that is not http(s), so a `file://`/`javascript:` template can
// never even become an underlined range. Rules are read through
// `linkRulesRef`, which is what makes editing them take effect without
// rebuilding the terminal.
const linkProvider = term.registerLinkProvider({
provideLinks: (bufferLineNumber, callback) => {
const buffer = term.buffer.active
@@ -1027,13 +1205,16 @@ export function TerminalView({
rowAt.push(row)
}
}
const links: ILink[] = findUrls(text).map((found) => {
const found = mergeLinkSpans(findRuleLinks(text, linkRulesRef.current), findUrls(text))
const links: ILink[] = found.map((link) => {
return {
range: {
start: { x: colAt[found.start] ?? 1, y: (rowAt[found.start] ?? first) + 1 },
end: { x: colAt[found.end - 1] ?? 1, y: (rowAt[found.end - 1] ?? first) + 1 }
start: { x: colAt[link.start] ?? 1, y: (rowAt[link.start] ?? first) + 1 },
end: { x: colAt[link.end - 1] ?? 1, y: (rowAt[link.end - 1] ?? first) + 1 }
},
text: found.url,
// The prompt shows the expanded target, so a template's result is
// visible before the click rather than after it.
text: link.url,
decorations: { pointerCursor: true, underline: true },
activate: (event: MouseEvent): void => {
const mod = navigator.platform.toLowerCase().includes('mac')
@@ -1041,7 +1222,7 @@ export function TerminalView({
: event.ctrlKey
if (!mod) return
// The main process routes window.open through shell.openExternal.
window.open(found.url, '_blank', 'noopener')
window.open(link.url, '_blank', 'noopener')
}
}
})
@@ -1252,7 +1433,7 @@ export function TerminalView({
const term = termRef.current
if (!term) return
term.options.fontSize = fontOpts.fontSize
term.options.fontFamily = fontOpts.fontFamily
term.options.fontFamily = quoteFontStack(appendCjk(fontOpts.fontFamily, fontOpts.cjkFontFamily))
term.options.fontWeight = toFontWeight(fontOpts.fontWeight)
term.options.fontWeightBold = toFontWeight(fontOpts.boldFontWeight)
term.options.letterSpacing = fontOpts.letterSpacing
+109
View File
@@ -0,0 +1,109 @@
/**
* CSS font-family quoting for the terminal font setting.
*
* The font enumerator (`src/main/ipc.ts`) asks the OS for plain names
* (`fontList.getFonts({ disableQuoting: true })`), so `settings.terminal.fontFamily`
* holds either one bare family name or a hand-written stack. The value is passed
* verbatim to xterm's `fontFamily` option and to the settings preview's inline
* style, where a name containing `(`, `)`, `+`, starting with a digit, or equal to
* a CSS keyword no longer parses as a CSS family — we have only been getting away
* with it because parsers are lenient. This module quotes each family on the way
* out; the stored setting keeps the user's original text.
*
* Splitting respects quotes: commas inside `'Name, with comma'` or `"Name"` do not
* separate families. Segments that are already quoted are kept as-is, bare
* CSS-safe identifiers (and the keywords that must stay bare) are untouched, and
* everything else gets double quotes with inner double quotes escaped. The result
* is idempotent, so re-running it on already-processed output changes nothing.
*
* No imports on purpose: `tests/font-stack.mjs` imports this .ts file directly and
* relies on Node >= 22.18 stripping the types.
*/
/** Family keywords that must stay unquoted to keep their CSS meaning. */
const CSS_KEYWORDS = new Set([
'monospace',
'serif',
'sans-serif',
'cursive',
'fantasy',
'system-ui',
'ui-monospace',
'default',
'inherit',
'initial',
'unset'
])
/** A CSS identifier that needs no quoting (letters, digits, `-`, `_`; no leading digit). */
const BARE_IDENTIFIER = /^[A-Za-z_-][A-Za-z0-9_-]*$/
/** Split a stack on top-level commas, leaving quoted spans (and their commas) intact. */
function splitTopLevel(stack: string): string[] {
const parts: string[] = []
let buffer = ''
let quote: '"' | "'" | null = null
for (let i = 0; i < stack.length; i += 1) {
const ch = stack[i]
if (quote) {
buffer += ch
if (ch === '\\' && i + 1 < stack.length) {
buffer += stack[i + 1]
i += 1
} else if (ch === quote) {
quote = null
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
buffer += ch
continue
}
if (ch === ',') {
parts.push(buffer)
buffer = ''
continue
}
buffer += ch
}
parts.push(buffer)
return parts
}
/** True when the segment is already wrapped in a matching quote pair. */
function isQuoted(name: string): boolean {
if (name.length < 2) return false
const first = name[0]
const last = name[name.length - 1]
return (first === '"' || first === "'") && first === last
}
/** True when the segment can stay bare: a CSS-safe identifier or a family keyword. */
function staysBare(name: string): boolean {
if (CSS_KEYWORDS.has(name.toLowerCase())) return true
return BARE_IDENTIFIER.test(name)
}
/**
* Normalize a font-family stack into a form CSS and canvas accept.
* Empty segments are dropped; an empty/blank input yields the empty string.
*/
export function quoteFontStack(family: string): string {
const out: string[] = []
for (const raw of splitTopLevel(family)) {
const name = raw.trim()
if (!name) continue
if (isQuoted(name) || staysBare(name)) {
out.push(name)
continue
}
// Escape backslashes as well as quotes: escaping only `"` turned a name with
// a trailing backslash (`a\`) into `"a\"`, whose closing quote is itself
// escaped — the CSS string never terminates and the font silently falls back.
out.push(`"${name.replace(/[\\"]/g, '\\$&')}"`)
}
return out.join(', ')
}
+299 -35
View File
@@ -12,7 +12,7 @@ import type { HighlightRule } from '@shared/settings'
export interface CompiledRule {
id: string
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
/** compiled with the 'g' flag (plus 'i'/'m' when the rule asks for them) */
regex: RegExp
/** hex fg, e.g. "#3fb950" — kept for introspection; `open` is precomputed from it */
fg: string
@@ -42,6 +42,21 @@ export interface RuleStat {
*/
export type StatsSink = Map<string, RuleStat>
/**
* Optional outlet for *match events*: called once per span the engine actually
* paints, with the owning rule's id and the matched text.
*
* "Actually paints" is the whole contract — a match another rule claimed first,
* one dropped by the per-rule budget, and a line skipped for being over
* MAX_LINE_LEN all report nothing, so a consumer never sees a hit that is not
* visible on screen. `text` is the raw matched substring: consumers that show
* something to the user must not render it (for the secret rule it *is* the
* secret); it is there for consumers that only need a shape or a length.
*
* Undefined on the default path: the engine skips the call entirely.
*/
export type MatchListener = (ruleId: string, text: string) => void
type Token = { kind: 'seq' | 'text'; value: string }
/** A claimed span carries its precomputed SGR open sequence, never a hex colour. */
type Span = { start: number; end: number; open: string }
@@ -62,23 +77,39 @@ type Span = { start: number; end: number; open: string }
* the leading bytes of a longer sequence.
*/
const ESCAPE_RE =
/(\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
/(\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
/** Chunks larger than this are returned unmodified (budget guard). */
const MAX_CHUNK = 512 * 1024
/**
* Longest newline-free segment of a plain-text run a user rule is applied to.
* Longest line a user rule is applied to.
* MAX_PER_RULE bounds the match *count*, not a pattern's backtracking *cost*:
* a pathological pattern like `(a+)+b` walks the whole line before the count
* can stop it, which freezes the renderer on one very long line. A run holding
* such a line is passed through unhighlighted — ordinary output (many short
* lines, whatever the chunk size) is unaffected, since the bound is per line,
* not per run.
* can stop it, which freezes the renderer on one very long line. Ordinary
* output (many short lines, whatever the chunk size) is unaffected, since the
* bound is per line, not per run.
*
* A run whose lines are all short is scanned whole, which is what lets rules
* match across a newline (`\\n/tmp` for the path rule, `^`-anchored presets at
* every line start). Only when the run holds an over-long line does `applyRun`
* degrade to line-at-a-time scanning: each short line is still scanned, the
* over-long one is passed through raw, and cross-line matches are given up for
* that run alone — one base64 blob or minified JSON no longer costs the rest
* of the run its highlighting.
*/
const MAX_LINE_LEN = 4 * 1024
/**
* Longest user pattern accepted. This is NOT a backtracking defence — a 30-char
* `(a+)+$` already burns ~8s on 30 chars (`MAX_LINE_LEN` bounds length, not
* cost) — it bounds the damage a stored rule can do and keeps the editor's
* live preview from freezing on a paste. Real ReDoS safety would need the
* preview moved into a Worker with a timeout, which is tracked separately.
*/
export const MAX_PATTERN_LEN = 500
/** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
let h = (hex || '').trim().replace(/^#/, '')
@@ -116,6 +147,11 @@ export function tokenize(input: string): Token[] {
return out
}
/** Flags a rule compiles with: global, plus `i` / `m` when the rule opts in. */
function flagsFor(rule: HighlightRule): string {
return `g${rule.caseInsensitive === true ? 'i' : ''}${rule.lineAnchor === true ? 'm' : ''}`
}
/** Compile only enabled + safely-compilable rules, sorted by priority asc. */
export function compileRules(rules: HighlightRule[]): CompiledRule[] {
const out: CompiledRule[] = []
@@ -123,12 +159,17 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
if (!rule || rule.enabled === false) continue
const pattern = rule.pattern
if (typeof pattern !== 'string' || pattern.length === 0) continue
// Bounded like the other per-rule guards: a huge pattern only costs compile
// time, but it also means someone pasted a blob into the editor.
if (pattern.length > MAX_PATTERN_LEN) continue
let regex: RegExp
try {
// 'g' always; 'i' when the rule asks for case-insensitive matching.
// 'g' always; 'i' when the rule asks for case-insensitive matching; 'm'
// when it asks for per-line anchoring, so `^ERROR$` fires on each line of
// a multi-line run instead of only on the run's first/last line.
// Lookbehinds (used by the preset success rule to leave `not ok` alone)
// need no special handling — they are plain V8 regex syntax.
regex = new RegExp(pattern, rule.caseInsensitive === true ? 'gi' : 'g')
regex = new RegExp(pattern, flagsFor(rule))
} catch {
continue // invalid pattern → silently skip
}
@@ -168,9 +209,113 @@ function compileBands(bands: HighlightRule['bands'], bgPart: string): CompiledRu
return kept
}
/** Wrap one plain-text span with its precomputed SGR open sequence. */
function wrap(text: string, open: string): string {
return `${open}${text}\x1b[0m`
/**
* SGR sequences replayed after a highlight span, so the span never disturbs the
* colours the *application* had active when it emitted the text.
*
* `wrap` closes a span with a reset; without a restore that reset also wipes
* whatever SGR the application set earlier in the stream, so
* `\x1b[33mWARNING tail` came back with `tail` in the default colour instead of
* the application's yellow. A CSI is only replayed when its final byte is `m`
* (SGR): cursor moves, OSC titles and the rest are state from another axis and
* replaying them would move the cursor or reset the title a second time.
*/
const SGR_ONLY_RE = /\x1b\[[0-9:;<=>?]*m/g
/**
* Cap on the accumulated restore string. Applications that repaint every cell
* emit thousands of SGRs between resets; replaying all of them after *every*
* span would multiply the chunk size by the span count. Past the cap the
* restore is dropped (plain reset), which is the pre-fix behaviour — bounded
* output matters more than an exact colour in that extreme.
*/
const MAX_RESTORE_LEN = 256
/**
* Replay budget per span, counted in SEQUENCES rather than bytes. The restore
* string is repeated for EVERY span in the run, so its real cost is
* (state size x spans): on Kimi-style TUI output (~19 spans/line) an unbounded
* restore inflated a 9.5KB chunk to 40.8KB (4.30x, vs 2.78x without it), and the
* renderer froze for 18.4s. Bytes are the wrong unit though — one foreground
* colour is `ESC[31m` (5B), `ESC[38;5;196m` (11B) or `ESC[38;2;r;g;bm` (up to
* 19B), so a byte cap silently kept indexed colours working while dropping
* truecolor. Counting sequences treats those three identically: replay the
* state when it is a couple of attributes, drop it when the application is
* stacking a whole paint job per cell.
*/
const MAX_REPLAY_SEQUENCES = 3
/**
* The application's active SGR state: every `…m` CSI emitted since the last
* reset, concatenated in order (`''` when the application has set none).
*
* It is tracked by `applyHighlights` and, crucially, *shared across chunks* by
* `HighlightStream`: a push that ends mid-stream leaves the colours set by
* earlier pushes in force for the next one, exactly as the terminal sees them.
* Without that, streamed output would only restore the colours set inside the
* same chunk and split-vs-whole output would diverge.
*/
class SgrState {
private value = ''
private count = 0
/** SGR state on entering the next text run (see `wrap`). Empty when the
* accumulated chain is too long to replay profitably: every span would pay
* for it, and a per-cell repainting TUI emits dozens of spans per line. */
get restore(): string {
return this.count > 0 && this.count <= MAX_REPLAY_SEQUENCES ? this.value : ''
}
/** Fold one escape-sequence token in; non-SGR tokens leave the state alone. */
absorb(token: string): void {
if (!token.endsWith('m') || token.charCodeAt(0) !== 0x1b) return
// A reset is any SGR whose parameters are all zero or empty — ECMA-48 says
// `ESC[m`, `ESC[0m`, `ESC[00m`, `ESC[0;0m` and friends all mean "reset
// everything". Matching only the two canonical spellings let the others be
// *appended* to the accumulator instead of clearing it, so the state grew a
// few bytes per line until it blew MAX_RESTORE_LEN and every later span lost
// its restore. `ESC[0;32m` (a reset plus a colour, common in Go/Java log
// libraries) must clear the state first and then record the new colour.
SGR_ONLY_RE.lastIndex = 0
let m: RegExpExecArray | null
// A token may hold several sequences; only the SGR ones are replayed.
while ((m = SGR_ONLY_RE.exec(token)) !== null) {
const seq = m[0]
const params = seq.slice(2, -1)
const resets = params === '' || /^[0;:]*$/.test(params)
if (resets) {
this.value = ''
this.count = 0
continue
}
// `ESC[0;32m` starts with a reset parameter: drop what came before it.
const afterReset = /^0+[;:](.*)$/.exec(params)
if (afterReset) {
this.value = ''
this.count = 0
}
this.value += seq
this.count += 1
if (this.value.length > MAX_RESTORE_LEN) {
// Too much state to replay economically: fall back to a plain reset
// rather than inflating every span by hundreds of bytes. Per-cell
// repainters (TUI frames) hit this constantly, and repeating the whole
// chain after each span multiplies chunk size by the span count.
this.value = ''
this.count = 0
return
}
}
}
}
/**
* Wrap one plain-text span in its precomputed SGR open sequence.
* `restore` is the application's SGR state on entering the run ('' when it had
* none), replayed after the reset so the span leaves the screen as it found it.
*/
function wrap(text: string, open: string, restore: string): string {
return `${open}${text}${restore === '' ? '\x1b[0m' : `\x1b[0m${restore}`}`
}
/**
@@ -236,23 +381,73 @@ function hasLongLine(text: string): boolean {
}
}
/**
* Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule
* remaining match allowance shared across the whole chunk). Returns the
* SGR-injected text. With a global budget of zero for every rule the run is
* returned untouched, as is a run holding an over-long line (see MAX_LINE_LEN).
*/
/** 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, spent?: { n: number }): string {
/**
* Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule
* remaining match allowance shared across the whole chunk). Returns the
* SGR-injected text.
*
* Two layers:
* - every line short (the normal case): the whole run is scanned in one pass,
* so a rule can match across a newline — untouched from before.
* - any line over MAX_LINE_LEN: degrade to scanning line by line, so the
* over-long line alone goes through raw while its short neighbours still
* highlight. Cross-line matches are given up for that run, which is the
* price of never handing a >4KB line to a user pattern.
*/
function applyRun(
text: string,
rules: CompiledRule[],
budgets: number[],
restore: string,
stats?: StatsSink,
spent?: { n: number },
onMatch?: MatchListener
): string {
if (text.length === 0) {
if (spent !== undefined) spent.n = 0
return ''
}
if (hasLongLine(text)) {
if (!hasLongLine(text)) return applyShortRun(text, rules, budgets, restore, stats, spent, onMatch)
let made = 0
let out = ''
// `\n` slices exactly, so joining the parts back reproduces the run byte for
// byte whatever happens to each line.
const lines = text.split('\n')
for (let i = 0; i < lines.length; i++) {
if (i > 0) out += '\n'
const line = lines[i]
if (line.length > MAX_LINE_LEN) {
out += line // too long to hand to a user pattern — passed through raw
continue
}
const runSpent = { n: 0 }
out += applyShortRun(line, rules, budgets, restore, stats, runSpent, onMatch)
made += runSpent.n
}
if (spent !== undefined) spent.n = made
return out
}
/**
* Scan one run that is known to be free of over-long lines. `restore` is the
* application's SGR state on entering the run (see `wrap`).
*/
function applyShortRun(
text: string,
rules: CompiledRule[],
budgets: number[],
restore: string,
stats?: StatsSink,
spent?: { n: number },
onMatch?: MatchListener
): string {
if (text.length === 0) {
if (spent !== undefined) spent.n = 0
return text
return ''
}
const claimed: Span[] = []
let totalMade = 0
@@ -275,6 +470,10 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
made++
totalMade++
budgets[ri]--
// Report only what will actually paint: `claim` returning true is the
// single point where a span becomes visible, so a stolen span, an
// exhausted budget and a skipped over-long line all stay silent here.
if (onMatch !== undefined) onMatch(rule.id, m[0])
if (budgets[ri] <= 0) break
}
if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse
@@ -289,11 +488,34 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
if (spent !== undefined) spent.n = totalMade
if (claimed.length === 0) return text
// `claim` keeps the spans sorted by start — no final sort needed here.
//
// `restore` is paid per span and its cost is the whole point of the budget:
// on a dense TUI line (Kimi emits ~19 spans/line) replaying it after every
// span inflated a 9.5KB chunk to 40.8KB (4.30x vs 2.78x), which froze the
// renderer for 18.4s. Two rules keep the colour where it is visible and skip
// it where it is not:
// - after the LAST span it always pays (that text runs to the next escape),
// - between spans it only pays when the gap is worth seeing — a gap of a
// space or two between two coloured words reads as separation either way.
// Gaps are measured on visible characters, and a long one (a mostly-plain line
// with a couple of matches) gets its colour back.
const MIN_GAP_FOR_RESTORE = 3
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.open)
const last = claimed.length - 1
for (let i = 0; i <= last; i++) {
const c = claimed[i]
if (c.start > pos) {
const gap = text.slice(pos, c.start)
// The gap between two spans carries the application's colour only if the
// previous span restored it. Restoring is worth it for a visible gap (a
// mostly-plain line with a couple of matches); a one- or two-character gap
// between two coloured words reads the same either way, and skipping it
// keeps dense TUI lines from being inflated span-by-span.
const restoreGap = i > 0 && gap.length >= MIN_GAP_FOR_RESTORE
out += restoreGap ? `${restore}${gap}` : gap
}
out += wrap(text.slice(c.start, c.end), c.open, i === last ? restore : '')
pos = c.end
}
if (pos < text.length) out += text.slice(pos)
@@ -305,12 +527,30 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
* there are no enabled rules, the chunk is empty, or it exceeds the budget.
* With a `stats` sink attached, per-rule hit counts and scan times are added to
* it — the settings page's stats view is the only caller that passes one.
*
* `sgr` carries the application's SGR state. It is optional: a one-shot caller
* (the settings preview) leaves it out and starts from "nothing set", while
* `HighlightStream` threads one instance through every chunk so a colour set in
* an earlier chunk is still restored in a later one.
*
* `onMatch` (see `MatchListener`) is the match-event outlet. Undefined on the
* default path — the check happens once inside the scan loop, per claimed span,
* so a caller that does not ask for events pays one `!== undefined` test per
* coloured span and nothing else. No de-duplication or rate limiting happens
* here: a rule that matched 200 times reports 200 times, and pacing is the
* consumer's business.
*/
export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: StatsSink): string {
export function applyHighlights(
chunk: string,
rules: CompiledRule[],
stats?: StatsSink,
sgr: SgrState = new SgrState(),
onMatch?: MatchListener
): string {
if (!chunk || rules.length === 0) return chunk
if (chunk.length > MAX_CHUNK) return chunk
let budgets = rules.map(() => MAX_PER_RULE)
const budgets = rules.map(() => MAX_PER_RULE)
let usedBudget = false
let out = ''
// applyRun reports how many matches it claimed, replacing the old two
@@ -319,6 +559,7 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
for (const token of tokenize(chunk)) {
if (token.kind === 'seq') {
out += token.value
sgr.absorb(token.value)
continue
}
// Double insurance: a residual raw ESC that somehow survives tokenization is
@@ -328,7 +569,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
continue
}
spent.n = 0
out += applyRun(token.value, rules, budgets, stats, spent)
// The state is read per run: a run's spans restore whatever was active when
// the run began, which is what the application had on screen at that point.
out += applyRun(token.value, rules, budgets, sgr.restore, stats, spent, onMatch)
if (spent.n > 0) usedBudget = true
}
return usedBudget ? out : chunk
@@ -436,12 +679,20 @@ const MAX_CARRY = 1024
export class HighlightStream {
private rules: CompiledRule[]
private stats?: StatsSink
private onMatch?: MatchListener
private buffer = ''
/** Whether `buffer` holds any ESC — lets push skip the O(buffer) escape scans on escape-free floods. */
private bufferHasEsc = false
/**
* The application's SGR state, kept for the lifetime of the stream: a colour
* set in one chunk stays in force for the next, so split output restores the
* same sequences as single-chunk output.
*/
private sgr = new SgrState()
constructor(rules: CompiledRule[]) {
constructor(rules: CompiledRule[], onMatch?: MatchListener) {
this.rules = rules
this.onMatch = onMatch
}
/** Swap the active rules without touching any buffered content. */
@@ -454,12 +705,25 @@ export class HighlightStream {
this.stats = stats
}
/** Attach or drop the match-event outlet (see `MatchListener`). */
setOnMatch(onMatch: MatchListener | undefined): void {
this.onMatch = onMatch
}
/** True while content is held back (a pending flush will release it). */
hasPending(): boolean {
return this.buffer.length > 0
}
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
/**
* Join held bytes with `chunk`, return content safe to hand to xterm.
*
* Every `applyHighlights` call below passes the same `onMatch`, so a match
* reports exactly once no matter which path (fast text path, incomplete-escape
* carry, closed-escape split) happened to release the bytes — a rule whose
* span straddles a chunk boundary still reports when it is finally painted,
* which is the property `hl-split-smoke` guards for the output bytes.
*/
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
@@ -471,7 +735,7 @@ export class HighlightStream {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
return applyHighlights(raw, this.rules, this.stats)
return applyHighlights(raw, this.rules, this.stats, this.sgr, this.onMatch)
}
return ''
}
@@ -487,11 +751,11 @@ export class HighlightStream {
// the fragment raw (applyHighlights skips raw \x1b in text runs).
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(head, this.rules, this.stats) + tail
return applyHighlights(head, this.rules, this.stats, this.sgr, this.onMatch) + tail
}
this.buffer = tail
this.bufferHasEsc = true // the held tail starts at the dangling ESC
return applyHighlights(head, this.rules, this.stats)
return applyHighlights(head, this.rules, this.stats, this.sgr, this.onMatch)
}
// (b) no incomplete escape -> find the last *closed* escape and emit only
@@ -504,7 +768,7 @@ export class HighlightStream {
const raw = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(raw, this.rules, this.stats)
return applyHighlights(raw, this.rules, this.stats, this.sgr, this.onMatch)
}
// The buffer (or the chunk that just joined it) brought the ESC in.
this.bufferHasEsc = true
@@ -513,7 +777,7 @@ export class HighlightStream {
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)
return applyHighlights(head, this.rules, this.stats, this.sgr, this.onMatch)
}
/** Flush any held content (session end) — highlighted as a final run. */
@@ -521,7 +785,7 @@ export class HighlightStream {
const b = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(b, this.rules, this.stats)
return applyHighlights(b, this.rules, this.stats, this.sgr, this.onMatch)
}
}
+63 -14
View File
@@ -3,36 +3,85 @@ import type { RuleStat } from './highlightEngine'
/**
* Live per-rule highlight counters, read by the settings page.
*
* The engine fills these only when a sink is attached, and `TerminalView`
* attaches one only while `settings.terminal.highlightStats` is on — so the
* default write path pays nothing. Totals are cumulative since the last reset;
* TerminalView publishes on a timer (not per chunk), because a busy terminal
* would otherwise re-render the settings page thousands of times a second.
* The engine fills a pane's own map only when a sink is attached, and
* `TerminalView` attaches one only while `settings.terminal.highlightStats` is
* on — so the default write path pays nothing. Totals are cumulative since the
* last reset, and panes publish on a timer (not per chunk), because a busy
* terminal would otherwise re-render the settings page thousands of times a
* second.
*
* A publish *registers* the pane rather than overwriting one shared slot: with
* a single slot, every open pane took turns winning it and the settings page
* showed whichever published last instead of a total. Readers get the merge
* across all panes (hits and ms summed per rule id), so a pane must unregister
* when it goes away — otherwise a closed pane would inflate that merge forever.
*/
let totals: Map<string, RuleStat> = new Map()
/** Opaque per-pane id → that pane's live sink map (kept by reference). */
const panes = new Map<string, Map<string, RuleStat>>()
let merged: Map<string, RuleStat> = new Map()
let updatedAt = 0
const listeners = new Set<() => void>()
export interface HighlightStatsSnapshot {
/** merged across every registered pane; a fresh map per publish */
rules: Map<string, RuleStat>
/** epoch ms of the last publish; the UI re-renders on change */
updatedAt: number
}
export function getHighlightStats(): HighlightStatsSnapshot {
return { rules: totals, updatedAt }
}
/** Hand the accumulated counters to the UI (TerminalView owns the map). */
export function publishHighlightStats(next: Map<string, RuleStat>): void {
totals = next
/**
* Fold every registered pane into one map. The accumulator objects are new, so
* nothing here can write into a pane's own counters. A single registered pane
* yields exactly its own numbers — the merge is a no-op with one pane.
*/
function recompute(): void {
const next = new Map<string, RuleStat>()
for (const pane of panes.values()) {
for (const [ruleId, stat] of pane) {
const acc = next.get(ruleId)
if (acc === undefined) {
next.set(ruleId, { hits: stat.hits, ms: stat.ms })
} else {
acc.hits += stat.hits
acc.ms += stat.ms
}
}
}
merged = next
updatedAt = Date.now()
for (const listener of listeners) listener()
}
export function getHighlightStats(): HighlightStatsSnapshot {
return { rules: merged, updatedAt }
}
/**
* Register (or refresh) one pane's counters and re-publish the merge.
*
* `paneId` has to stay stable for the pane's lifetime: the map is held by
* reference, so re-publishing the same id simply re-reads that pane's numbers.
*/
export function publishHighlightStats(paneId: string, stats: Map<string, RuleStat>): void {
panes.set(paneId, stats)
recompute()
}
/** Drop a pane's counters — called when its `TerminalView` unmounts. */
export function unregisterHighlightStats(paneId: string): void {
if (!panes.delete(paneId)) return
recompute()
}
/**
* Zero every registered pane. Clearing the panes' own maps (not just the
* merge) is what makes a reset stick: the live panes keep accumulating into
* those same maps, so a merge-only reset would be undone by the next publish.
*/
export function resetHighlightStats(): void {
publishHighlightStats(new Map())
for (const pane of panes.values()) pane.clear()
recompute()
}
export function subscribeHighlightStats(listener: () => void): () => void {
+7 -6
View File
@@ -1,5 +1,5 @@
import type { ThemeColors } from '@shared/theme'
import { contrast, darkenToRatio, isLightTheme, mix, semanticCssVars } from './semantic'
import { contrast, darkenToRatio, isLightTheme, mix, semanticVars } from './semantic'
// 兼容既有导入方(main.tsx 的 mix);新代码请直接从 ./semantic 导入。
export { contrast, luminance, mix } from './semantic'
@@ -65,11 +65,12 @@ export function applyChromeTheme(colors: ThemeColors): void {
// styles. The dark branch locks the SEMANTIC anchors (zero visual change);
// the light branch darkens them until the WCAG ratio holds against this
// theme's background. `color-scheme` rides along so native scrollbars and
// form controls follow the same switch. semanticCssVars() owns the camelCase
// -> kebab-case naming (custom property names are case-sensitive, so a miss
// would leave the variable unset and silently fall back to the dark :root
// literal); tests/ot-var-names.mjs guards it.
for (const [name, value] of Object.entries(semanticCssVars(colors, chromeIsLight))) set(name, value)
// form controls follow the same switch. semanticVars() keys ARE the final
// CSS custom property names (single naming system — custom property names
// are case-sensitive, so a camelCase intermediate would leave the variable
// unset and silently fall back to the dark :root literal); the literal key
// union type plus tests/ot-var-names.mjs guard it.
for (const [name, value] of Object.entries(semanticVars(colors, chromeIsLight))) set(name, value)
applyTabAccent(tabAccent)
}
+78 -62
View File
@@ -88,74 +88,90 @@ export const SEMANTIC = {
focusRing: 'rgba(59, 142, 234, 0.2)'
} as const
/** Values for --ot-* variables (camelCase keys; chrome.ts converts them to
* kebab-case, so `textMuted` lands as `--ot-text-muted`). */
export function semanticVars(colors: ThemeLike, light: boolean): Record<string, string> {
/** The CSS custom property names semanticVars() emits. There is exactly ONE
* naming system: the keys ARE the final names, so a typo is a type error at
* the read site instead of a silently unset variable (custom property names
* are case-sensitive — a camelCase key would never match `var(--ot-*)` and the
* element would fall back to the dark :root literal). tests/ot-var-names.mjs
* asserts every key matches /^--ot-[a-z0-9-]+$/ so camelCase cannot creep
* back in. Keep this union and the two branches in semanticVars() in sync. */
export type OtVarName =
| '--ot-text'
| '--ot-text-secondary'
| '--ot-text-muted'
| '--ot-surface'
| '--ot-surface-raised'
| '--ot-hover'
| '--ot-border'
| '--ot-border-strong'
| '--ot-success'
| '--ot-success-fg'
| '--ot-danger'
| '--ot-danger-fg'
| '--ot-warning'
| '--ot-warning-fg'
| '--ot-info'
| '--ot-info-fg'
| '--ot-scrollbar'
| '--ot-scrollbar-active'
| '--ot-shadow'
| '--ot-focus-ring'
/** semanticVars() result. `color-scheme` is not an --ot-* variable but rides
* along so native scrollbars and form controls follow the same switch. */
export type SemanticVars = Record<OtVarName | 'color-scheme', string>
/** Values for the --ot-* variables, keyed by their final CSS custom property
* names (chrome.ts writes them straight onto <html>). */
export function semanticVars(colors: ThemeLike, light: boolean): SemanticVars {
const bg = colors.background
const fg = colors.foreground
if (!light) {
return {
text: fg,
textSecondary: mix(bg, fg, 0.78) ?? fg,
textMuted: SEMANTIC.muted,
surface: mix(bg, fg, 0.05) ?? bg,
surfaceRaised: SEMANTIC.surfaceRaised,
hover: SEMANTIC.hover,
border: mix(bg, fg, 0.16) ?? bg,
borderStrong: SEMANTIC.borderStrong,
success: SEMANTIC.success,
successFg: SEMANTIC.success,
danger: SEMANTIC.danger,
dangerFg: SEMANTIC.danger,
warning: SEMANTIC.warning,
warningFg: SEMANTIC.warning,
info: SEMANTIC.info,
infoFg: SEMANTIC.info,
scrollbar: SEMANTIC.scrollbar,
scrollbarActive: SEMANTIC.scrollbarActive,
shadow: SEMANTIC.shadow,
focusRing: SEMANTIC.focusRing,
colorScheme: 'dark'
'--ot-text': fg,
'--ot-text-secondary': mix(bg, fg, 0.78) ?? fg,
'--ot-text-muted': SEMANTIC.muted,
'--ot-surface': mix(bg, fg, 0.05) ?? bg,
'--ot-surface-raised': SEMANTIC.surfaceRaised,
'--ot-hover': SEMANTIC.hover,
'--ot-border': mix(bg, fg, 0.16) ?? bg,
'--ot-border-strong': SEMANTIC.borderStrong,
'--ot-success': SEMANTIC.success,
'--ot-success-fg': SEMANTIC.success,
'--ot-danger': SEMANTIC.danger,
'--ot-danger-fg': SEMANTIC.danger,
'--ot-warning': SEMANTIC.warning,
'--ot-warning-fg': SEMANTIC.warning,
'--ot-info': SEMANTIC.info,
'--ot-info-fg': SEMANTIC.info,
'--ot-scrollbar': SEMANTIC.scrollbar,
'--ot-scrollbar-active': SEMANTIC.scrollbarActive,
'--ot-shadow': SEMANTIC.shadow,
'--ot-focus-ring': SEMANTIC.focusRing,
'color-scheme': 'dark'
}
}
return {
text: darkenToRatio(fg, bg, 7),
textSecondary: darkenToRatio(mix(bg, fg, 0.75) ?? fg, bg, 4.5),
textMuted: darkenToRatio(SEMANTIC.muted, bg, 4.5),
surface: mix(bg, fg, 0.04) ?? bg,
surfaceRaised: mix(bg, '#000000', 0.045) ?? bg,
hover: 'rgba(0, 0, 0, 0.06)',
border: mix(bg, '#000000', 0.14) ?? bg,
borderStrong: 'rgba(0, 0, 0, 0.22)',
success: darkenToRatio(SEMANTIC.success, bg, 3),
successFg: darkenToRatio(SEMANTIC.success, bg, 4.5),
danger: darkenToRatio(SEMANTIC.danger, bg, 3),
dangerFg: darkenToRatio(SEMANTIC.danger, bg, 4.5),
warning: darkenToRatio(SEMANTIC.warning, bg, 3),
warningFg: darkenToRatio(SEMANTIC.warning, bg, 4.5),
info: darkenToRatio(SEMANTIC.info, bg, 3),
infoFg: darkenToRatio(SEMANTIC.info, bg, 4.5),
scrollbar: 'rgba(0, 0, 0, 0.28)',
scrollbarActive: 'rgba(0, 0, 0, 0.45)',
shadow: 'rgba(0, 0, 0, 0.15)',
focusRing: SEMANTIC.focusRing,
colorScheme: 'light'
'--ot-text': darkenToRatio(fg, bg, 7),
'--ot-text-secondary': darkenToRatio(mix(bg, fg, 0.75) ?? fg, bg, 4.5),
'--ot-text-muted': darkenToRatio(SEMANTIC.muted, bg, 4.5),
'--ot-surface': mix(bg, fg, 0.04) ?? bg,
'--ot-surface-raised': mix(bg, '#000000', 0.045) ?? bg,
'--ot-hover': 'rgba(0, 0, 0, 0.06)',
'--ot-border': mix(bg, '#000000', 0.14) ?? bg,
'--ot-border-strong': 'rgba(0, 0, 0, 0.22)',
'--ot-success': darkenToRatio(SEMANTIC.success, bg, 3),
'--ot-success-fg': darkenToRatio(SEMANTIC.success, bg, 4.5),
'--ot-danger': darkenToRatio(SEMANTIC.danger, bg, 3),
'--ot-danger-fg': darkenToRatio(SEMANTIC.danger, bg, 4.5),
'--ot-warning': darkenToRatio(SEMANTIC.warning, bg, 3),
'--ot-warning-fg': darkenToRatio(SEMANTIC.warning, bg, 4.5),
'--ot-info': darkenToRatio(SEMANTIC.info, bg, 3),
'--ot-info-fg': darkenToRatio(SEMANTIC.info, bg, 4.5),
'--ot-scrollbar': 'rgba(0, 0, 0, 0.28)',
'--ot-scrollbar-active': 'rgba(0, 0, 0, 0.45)',
'--ot-shadow': 'rgba(0, 0, 0, 0.15)',
'--ot-focus-ring': SEMANTIC.focusRing,
'color-scheme': 'light'
}
}
/** Final CSS custom property name for a semanticVars() key. Custom property
* names are case-sensitive, so this kebab conversion is the only thing tying
* the camelCase keys to the --ot-* names the stylesheets read — a miss leaves
* the variable unset and the element falls back to the dark :root literal.
* Exported so chrome.ts and tests/ot-var-names.mjs share one implementation. */
export function cssVarName(key: string): string {
if (key === 'colorScheme') return 'color-scheme'
return `--ot-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
}
/** semanticVars() keyed by their final CSS custom property names. */
export function semanticCssVars(colors: ThemeLike, light: boolean): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(semanticVars(colors, light))) out[cssVarName(key)] = value
return out
}
+2
View File
@@ -126,6 +126,8 @@ function coerceImported(value: unknown, index: number, warnings: string[]): High
color: { fg, ...(bg ? { bg } : {}) },
...(Array.isArray(raw.bands) ? { bands: raw.bands as HighlightRule['bands'] } : {}),
...(raw.caseInsensitive === true ? { caseInsensitive: true } : {}),
...(raw.lineAnchor === true ? { lineAnchor: true } : {}),
...(raw.notify === true ? { notify: true } : {}),
...(raw.basic === true ? { basic: true } : {}),
...(isHighlightCategory(raw.category) ? { category: raw.category } : {}),
...(typeof raw.note === 'string' && raw.note.trim() !== '' ? { note: raw.note.trim() } : {}),
+26 -1
View File
@@ -33,9 +33,14 @@ const settings: Record<string, string> = {
'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.desc': 'Primary terminal font: the built-in default or any system font (prefer ones tagged monospace)',
'settings.font.placeholder': 'Select a font',
'settings.font.default': 'Default',
'settings.font.monoBadge': 'MONO',
'settings.font.cjkFamily': 'CJK font',
'settings.font.cjkFamilyNone': 'Default (append nothing)',
'settings.font.cjkFamilyExtra':
'Appended to the end of the font stack, used only as the CJK fallback. Pick a monospace CJK face such as Sarasa Mono SC',
'settings.font.size': 'Font size',
'settings.font.sizeDesc': 'Pixel size of the terminal text',
'settings.font.weight': 'Weight',
@@ -188,6 +193,7 @@ const settings: Record<string, string> = {
'settings.highlight.enabled': 'Enabled',
'settings.highlight.pattern': 'Pattern',
'settings.highlight.patternPlaceholder': 'e.g. \\b(ERROR|FAILED)\\b',
'settings.highlight.patternSlow': 'This pattern may be extremely slow (catastrophic backtracking) and can freeze the UI on longer output — consider simplifying it',
'settings.highlight.invalidRegex': 'Invalid regular expression',
'settings.highlight.priority': 'Priority',
'settings.highlight.priorityHint': 'Lower values apply first',
@@ -200,6 +206,12 @@ const settings: Record<string, string> = {
'settings.highlight.notePlaceholder': 'Rule description (optional)',
'settings.highlight.caseInsensitive': 'Ignore case',
'settings.highlight.caseInsensitiveHint': 'letters match in any case (OK also matches ok)',
'settings.highlight.lineAnchor': 'Match per line',
'settings.highlight.lineAnchorHint': '^ and $ anchor to each line (otherwise only the start and end of the whole output)',
'settings.highlight.notify': 'Alert on hit',
'settings.highlight.notifyHint': 'Show one in-app alert when this rule matches (at most once a minute)',
'settings.highlight.notifyTitle': 'Highlight rule matched',
'settings.highlight.notifyBody': 'Rule "{name}" matched in the terminal output',
'settings.highlight.bands': 'Value bands',
'settings.highlight.bandsHint':
'Colour by the first number in the match; no number (or below the lowest band) keeps the foreground colour',
@@ -272,6 +284,19 @@ const settings: Record<string, string> = {
'settings.highlight.profileExcluded': 'Excluded {n}: {list}',
'settings.highlight.profileEmpty': 'No rule sets yet',
'settings.highlight.profileDeleteTitle': 'Delete rule set',
'settings.highlight.linksTitle': 'Clickable link rules',
'settings.highlight.linksHint': 'Match terminal output with a regex and turn the hit into a clickable link; Ctrl+Click (Cmd on macOS) opens the expanded template in your browser (http / https only)',
'settings.highlight.linkAdd': '+ Add link rule',
'settings.highlight.linkEmpty': 'No link rules yet — click "+ Add link rule" to create one',
'settings.highlight.linkEditTitle': 'Edit link rule',
'settings.highlight.linkCreateTitle': 'New link rule',
'settings.highlight.linkDeleteTitle': 'Delete link rule',
'settings.highlight.linkPatternHint': 'Runs against the whole logical line (wrapped rows are joined first); use groups for the parts you want to keep',
'settings.highlight.linkTemplate': 'Link template',
'settings.highlight.linkTemplateHint': '$0 is the whole match, $1–$9 the capture groups, $$ a literal $; the template must expand to an http:// or https:// link',
'settings.highlight.linkInvalidTemplate': 'The template must expand to an http:// or https:// link',
'settings.highlight.linkPattern': 'Pattern',
'settings.highlight.linkEnabled': 'Enabled',
'settings.highlight.actions': 'Actions',
'settings.highlight.addRule': '+ Add rule',
'settings.highlight.empty': 'No highlight rules yet — click "+ Add rule" to create one',
+26 -1
View File
@@ -33,9 +33,14 @@ const settings: Record<string, string> = {
'settings.color.ansi.brightCyan': '明るいシアン',
'settings.color.ansi.brightWhite': '明るい白',
'settings.font.label': 'フォント',
'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント',
'settings.font.desc': 'ターミナルのメインフォント:組み込みの既定フォント、または任意のシステムフォント(等幅バッジ付きのものを推奨)',
'settings.font.placeholder': 'フォントを選択',
'settings.font.default': 'デフォルト',
'settings.font.monoBadge': '等幅',
'settings.font.cjkFamily': '中国語フォント',
'settings.font.cjkFamilyNone': 'デフォルト(追加しない)',
'settings.font.cjkFamilyExtra':
'フォントスタックの末尾に追加し、中国語のフォールバックにのみ使用します。Sarasa Mono SC などの等幅の中国語フォントが適しています',
'settings.font.size': '文字サイズ',
'settings.font.sizeDesc': 'ターミナル文字のピクセルサイズ',
'settings.font.weight': '文字の太さ',
@@ -185,6 +190,7 @@ const settings: Record<string, string> = {
'settings.highlight.enabled': '有効',
'settings.highlight.pattern': '正規表現',
'settings.highlight.patternPlaceholder': '例: \\b(ERROR|FAILED)\\b',
'settings.highlight.patternSlow': 'この正規表現は非常に遅くなる可能性があります(壊滅的なバックトラック)。長い出力で UI が固まる恐れがあるため、簡略化を検討してください',
'settings.highlight.invalidRegex': '正規表現が無効です',
'settings.highlight.priority': '優先度',
'settings.highlight.priorityHint': '値が小さいほど先に適用されます',
@@ -197,6 +203,12 @@ const settings: Record<string, string> = {
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
'settings.highlight.caseInsensitive': '大文字小文字を無視',
'settings.highlight.caseInsensitiveHint': '大文字小文字を区別しない(OK は ok にも一致)',
'settings.highlight.lineAnchor': '行単位でマッチ',
'settings.highlight.lineAnchorHint': '^ と $ を行頭・行末に合わせる(既定では出力全体の先頭と末尾のみ)',
'settings.highlight.notify': 'マッチ時に通知',
'settings.highlight.notifyHint': 'このルールがマッチしたときにアプリ内通知を 1 回表示する(1 分に最大 1 回)',
'settings.highlight.notifyTitle': 'ハイライトルールがマッチしました',
'settings.highlight.notifyBody': 'ルール「{name}」がターミナル出力でマッチしました',
'settings.highlight.bands': '数値の段階',
'settings.highlight.bandsHint': 'マッチ内の最初の数値で色を決めます(数値なし・最小段階未満は前景色)',
'settings.highlight.bandMin': '下限',
@@ -267,6 +279,19 @@ const settings: Record<string, string> = {
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
'settings.highlight.linksTitle': 'クリック可能なリンクルール',
'settings.highlight.linksHint': '正規表現でターミナル出力を照合し、一致した部分をクリック可能なリンクにします。Ctrl+クリック(macOS は Cmd)でテンプレートを展開した URL をブラウザーで開きます(http / https のみ)',
'settings.highlight.linkAdd': '+ リンクルールを追加',
'settings.highlight.linkEmpty': 'リンクルールがありません。「+ リンクルールを追加」から作成できます',
'settings.highlight.linkEditTitle': 'リンクルールを編集',
'settings.highlight.linkCreateTitle': 'リンクルールを追加',
'settings.highlight.linkDeleteTitle': 'リンクルールを削除',
'settings.highlight.linkPatternHint': '論理行全体(折り返し行は連結済み)に対して照合します。使いたい部分はグループで囲んでください',
'settings.highlight.linkTemplate': 'リンクテンプレート',
'settings.highlight.linkTemplateHint': '$0 は一致全体、$1–$9 はキャプチャーグループ、$$ はリテラルの $。テンプレートは http:// または https:// で始まる URL に展開される必要があります',
'settings.highlight.linkInvalidTemplate': 'テンプレートは http:// または https:// で始まる URL に展開される必要があります',
'settings.highlight.linkPattern': '正規表現',
'settings.highlight.linkEnabled': '有効',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ ルールを追加',
'settings.highlight.empty': 'ハイライトルールがありません。「+ ルールを追加」から作成できます',
+26 -1
View File
@@ -33,9 +33,14 @@ const settings: Record<string, string> = {
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字体',
'settings.font.desc': '内置默认字体或系统等宽字体',
'settings.font.desc': '终端主字体:内置默认字体,或任意系统字体(推荐选择带等宽徽标的)',
'settings.font.placeholder': '选择字体',
'settings.font.default': '默认',
'settings.font.monoBadge': '等宽',
'settings.font.cjkFamily': '中文字体',
'settings.font.cjkFamilyNone': '默认(不追加)',
'settings.font.cjkFamilyExtra':
'追加到字体栈末尾,仅用于中文回退。适合选择等宽中文字体,如更纱黑体 / Sarasa Mono SC',
'settings.font.size': '字号',
'settings.font.sizeDesc': '终端文字的像素大小',
'settings.font.weight': '字重',
@@ -177,6 +182,7 @@ const settings: Record<string, string> = {
'settings.highlight.enabled': '启用',
'settings.highlight.pattern': '正则',
'settings.highlight.patternPlaceholder': '例如 \\b(ERROR|FAILED)\\b',
'settings.highlight.patternSlow': '这条正则可能极慢(灾难性回溯),终端输出较长时会卡死界面,建议简化',
'settings.highlight.invalidRegex': '正则表达式无效',
'settings.highlight.priority': '优先级',
'settings.highlight.priorityHint': '值越小越先应用',
@@ -189,6 +195,12 @@ const settings: Record<string, string> = {
'settings.highlight.notePlaceholder': '规则说明(可选)',
'settings.highlight.caseInsensitive': '忽略大小写',
'settings.highlight.caseInsensitiveHint': '字母不区分大小写(OK 也能匹配 ok)',
'settings.highlight.lineAnchor': '按行匹配',
'settings.highlight.lineAnchorHint': '^ 和 $ 匹配每一行的开头和结尾(否则只匹配整段输出的首尾)',
'settings.highlight.notify': '命中时提醒',
'settings.highlight.notifyHint': '命中该规则时弹一次应用内提醒(每分钟最多一次)',
'settings.highlight.notifyTitle': '高亮规则命中提醒',
'settings.highlight.notifyBody': '规则「{name}」在终端输出中命中',
'settings.highlight.bands': '数值分级',
'settings.highlight.bandsHint': '按匹配到的第一个数字取色;没有数字或低于最小分级时用前景色',
'settings.highlight.bandMin': '下限',
@@ -254,6 +266,19 @@ const settings: Record<string, string> = {
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
'settings.highlight.profileEmpty': '尚未创建规则集',
'settings.highlight.profileDeleteTitle': '删除规则集',
'settings.highlight.linksTitle': '可点击链接规则',
'settings.highlight.linksHint': '用正则匹配终端输出,命中后变成可点击链接;按住 Ctrl(macOS 为 Cmd)点击,在系统浏览器中按模板打开(只支持 http / https)',
'settings.highlight.linkAdd': '+ 新增链接规则',
'settings.highlight.linkEmpty': '暂无链接规则,点击「+ 新增链接规则」创建',
'settings.highlight.linkEditTitle': '编辑链接规则',
'settings.highlight.linkCreateTitle': '新增链接规则',
'settings.highlight.linkDeleteTitle': '删除链接规则',
'settings.highlight.linkPatternHint': '对终端的一整行(跨换行会先拼接)执行匹配;建议用分组圈出要用的部分',
'settings.highlight.linkTemplate': '链接模板',
'settings.highlight.linkTemplateHint': '$0 为整段匹配,$1–$9 为第 1–9 个分组,$$ 为字面 $;模板必须以 http:// 或 https:// 开头才会生效',
'settings.highlight.linkInvalidTemplate': '模板必须展开成 http:// 或 https:// 开头的链接',
'settings.highlight.linkPattern': '正则',
'settings.highlight.linkEnabled': '启用',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ 新增规则',
'settings.highlight.empty': '暂无高亮规则,点击「+ 新增规则」创建',
+26 -1
View File
@@ -33,9 +33,14 @@ const settings: Record<string, string> = {
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字型',
'settings.font.desc': '內建預設字型或系統等寬字型',
'settings.font.desc': '終端主字型:內建預設字型,或任意系統字型(建議選擇帶等寬徽標的)',
'settings.font.placeholder': '選擇字型',
'settings.font.default': '預設',
'settings.font.monoBadge': '等寬',
'settings.font.cjkFamily': '中文字型',
'settings.font.cjkFamilyNone': '預設(不追加)',
'settings.font.cjkFamilyExtra':
'追加到字型堆疊末端,僅用於中文回退。適合選擇等寬中文字型,如更紗黑體 / Sarasa Mono SC',
'settings.font.size': '字型大小',
'settings.font.sizeDesc': '終端文字的像素大小',
'settings.font.weight': '字重',
@@ -177,6 +182,7 @@ const settings: Record<string, string> = {
'settings.highlight.enabled': '啟用',
'settings.highlight.pattern': '正規表示式',
'settings.highlight.patternPlaceholder': '例如 \\b(ERROR|FAILED)\\b',
'settings.highlight.patternSlow': '這條正則可能極慢(災難性回溯),終端輸出較長時會卡死介面,建議簡化',
'settings.highlight.invalidRegex': '正規表示式無效',
'settings.highlight.priority': '優先順序',
'settings.highlight.priorityHint': '值越小越先套用',
@@ -189,6 +195,12 @@ const settings: Record<string, string> = {
'settings.highlight.notePlaceholder': '規則說明(選填)',
'settings.highlight.caseInsensitive': '忽略大小寫',
'settings.highlight.caseInsensitiveHint': '字母不區分大小寫(OK 也能匹配 ok)',
'settings.highlight.lineAnchor': '按行匹配',
'settings.highlight.lineAnchorHint': '^ 和 $ 匹配每一行的開頭和結尾(否則只匹配整段輸出的首尾)',
'settings.highlight.notify': '命中時提醒',
'settings.highlight.notifyHint': '命中該規則時彈一次應用內提醒(每分鐘最多一次)',
'settings.highlight.notifyTitle': '高亮規則命中提醒',
'settings.highlight.notifyBody': '規則「{name}」在終端輸出中命中',
'settings.highlight.bands': '數值分級',
'settings.highlight.bandsHint': '依匹配到的第一個數字取色;沒有數字或低於最小分級時用前景色',
'settings.highlight.bandMin': '下限',
@@ -254,6 +266,19 @@ const settings: Record<string, string> = {
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
'settings.highlight.profileEmpty': '尚未建立規則集',
'settings.highlight.profileDeleteTitle': '刪除規則集',
'settings.highlight.linksTitle': '可點擊連結規則',
'settings.highlight.linksHint': '用正則表示式比對終端輸出,命中後變成可點擊連結;按住 Ctrl(macOS 為 Cmd)點擊,在系統瀏覽器中依模板開啟(僅支援 http / https)',
'settings.highlight.linkAdd': '+ 新增連結規則',
'settings.highlight.linkEmpty': '尚無連結規則,點擊「+ 新增連結規則」建立',
'settings.highlight.linkEditTitle': '編輯連結規則',
'settings.highlight.linkCreateTitle': '新增連結規則',
'settings.highlight.linkDeleteTitle': '刪除連結規則',
'settings.highlight.linkPatternHint': '對終端的一整行(跨換行會先拼接)執行比對;建議用分組圈出要用的部分',
'settings.highlight.linkTemplate': '連結模板',
'settings.highlight.linkTemplateHint': '$0 為整段比對,$1–$9 為第 1–9 個分組,$$ 為字面 $;模板必須展開成 http:// 或 https:// 開頭的連結',
'settings.highlight.linkInvalidTemplate': '模板必須展開成 http:// 或 https:// 開頭的連結',
'settings.highlight.linkPattern': '正則',
'settings.highlight.linkEnabled': '啟用',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ 新增規則',
'settings.highlight.empty': '尚無高亮規則,點擊「+ 新增規則」建立',
+274
View File
@@ -0,0 +1,274 @@
import type { LinkRule } from './settings'
/**
* User-defined clickable link rules (WezTerm `hyperlink_rules` in spirit).
*
* A rule is a regex over the terminal's *logical* line plus a URL template: the
* match becomes an underlined range, and a Ctrl/Cmd+Click opens the template
* expanded with the capture groups. This module is the whole engine minus the
* xterm plumbing — validation, template expansion and the non-overlapping span
* merge — so it stays a zero-import pure module the Node test can load
* directly (`tests/link-rules.mjs`), and nothing here can touch a terminal.
*
* Safety: `findRuleLinks` is the only producer of a rule-made target, and it
* drops anything whose scheme is not http/https. A template is user input, and
* `file://`, `javascript:` or an `ms-settings:` handler must never reach the
* shell just because a pattern matched some output.
*/
/** Longest pattern / template we accept; longer entries are dropped. */
export const LINK_PATTERN_MAX = 500
export const LINK_TEMPLATE_MAX = 500
/** Note is a table caption, not documentation — same budget as a highlight rule. */
export const LINK_NOTE_MAX = 60
/** Upper bound on stored rules, so a runaway import cannot bloat settings.json. */
export const LINK_RULES_MAX = 100
/**
* User patterns run against the rebuilt logical line. Past this length they are
* skipped entirely: built-in URL matching is a fixed set of regexes, but a
* user-supplied one can backtrack catastrophically, and a terminal can emit a
* single-line log record of arbitrary length.
*/
export const LINK_TEXT_MAX = 4096
/** Per-rule match cap for one line, same reasoning as the text cap. */
const MAX_PER_RULE = 200
/**
* Stable empty list for `settings.linkRules`. The field is optional, and a
* selector that returned a fresh `[]` on every read would hand zustand's
* equality check a new identity each time — re-rendering every terminal pane on
* unrelated settings writes. Never mutated: every writer builds a new array.
*/
export const EMPTY_LINK_RULES: LinkRule[] = []
export interface CompiledLinkRule {
id: string
regex: RegExp
template: string
}
/** A clickable range, in the same shape `urlLinks.findUrls` returns. */
export interface LinkSpan {
/** absolute offset of the first character, within the logical line */
start: number
/** absolute offset one past the last character */
end: number
/** the clickable target, scheme already checked */
url: string
}
/**
* Validate a rule list coming from disk. Ids, patterns and templates are
* required; a pattern that does not compile, an empty template and duplicate
* ids are dropped (the settings table keys rows by id, and an uncompilable
* regex would otherwise throw on every line the terminal draws).
*/
export function sanitizeLinkRules(
raw: unknown,
warn?: (message: string) => void
): LinkRule[] {
if (!Array.isArray(raw)) return []
const rules: LinkRule[] = []
const seen = new Set<string>()
raw.forEach((entry, index) => {
if (entry === null || typeof entry !== 'object') {
warn?.(`linkRules[${index}]: not an object — skipped`)
return
}
const candidate = entry as Record<string, unknown>
const { id, pattern, template } = candidate
if (typeof id !== 'string' || id === '') {
warn?.(`linkRules[${index}]: missing id — skipped`)
return
}
if (seen.has(id)) {
warn?.(`linkRules[${index}]: duplicate id — skipped`)
return
}
if (typeof pattern !== 'string' || pattern.trim() === '') {
warn?.(`linkRules[${index}]: missing pattern — skipped`)
return
}
if (typeof template !== 'string' || template.trim() === '') {
warn?.(`linkRules[${index}]: missing template — skipped`)
return
}
if (pattern.length > LINK_PATTERN_MAX) {
warn?.(`linkRules[${index}]: pattern too long — skipped`)
return
}
if (template.length > LINK_TEMPLATE_MAX) {
warn?.(`linkRules[${index}]: template too long — skipped`)
return
}
if (!compiles(pattern)) {
warn?.(`linkRules[${index}]: invalid regex — skipped`)
return
}
if (rules.length >= LINK_RULES_MAX) {
warn?.(`linkRules[${index}]: too many rules — skipped`)
return
}
seen.add(id)
const note = typeof candidate.note === 'string' ? candidate.note.trim() : ''
rules.push({
id,
pattern,
template,
// Absent means on: a rule the user just created and did not switch off.
enabled: candidate.enabled !== false,
...(note ? { note: note.slice(0, LINK_NOTE_MAX) } : {})
})
})
return rules
}
function compiles(pattern: string): boolean {
try {
// eslint-disable-next-line no-new
new RegExp(pattern)
return true
} catch {
return false
}
}
/**
* Expand a template against one match: `$0` is the whole match, `$1`–`$9` the
* capture groups (a group that did not participate expands to nothing), `$$`
* is a literal `$`, and a `$` before anything else stays a literal `$`.
*
* Only one digit is ever consumed, so `$10` is group 1 followed by a literal
* `0` — a rule with ten groups is out of scope, and resolving `$10` to a group
* the match may not have would be the more surprising of the two readings.
*/
export function formatLinkTemplate(template: string, match: RegExpExecArray): string {
let out = ''
for (let i = 0; i < template.length; i++) {
const char = template[i]
if (char !== '$' || i + 1 >= template.length) {
out += char
continue
}
const next = template[i + 1]
if (next === '$') {
out += '$'
i++
continue
}
if (next >= '0' && next <= '9') {
out += match[Number(next)] ?? ''
i++
continue
}
out += char
}
return out
}
/**
* Whether a generated target may be handed to the OS. Only the web schemes are
* allowed — the same gate the main process applies to `window.open`, repeated
* here so an unusable target never even becomes an underlined range.
*/
export function isSafeLinkTarget(url: string): boolean {
const trimmed = url.trim()
if (trimmed === '') return false
try {
const { protocol } = new URL(trimmed)
return protocol === 'http:' || protocol === 'https:'
} catch {
// unparsable / relative: nothing a browser could open
return false
}
}
/**
* Whether a *template* can produce an openable target — the check the rule
* editor needs before a match exists, so it judges the shape rather than one
* expansion:
*
* - a template that names `http://` or `https://` itself is fine;
* - one that starts with `$0`/`$1`–`$9` takes the scheme from the match, so it
* is accepted here and left to `isSafeLinkTarget` at click time (a `$0`
* rule over `https?://\S+` is the legitimate case);
* - anything else — `file:`, `javascript:`, a bare path, a leading space
* followed by a foreign scheme — is refused.
*/
export function isSafeLinkTemplate(template: string): boolean {
const trimmed = template.trim()
if (trimmed === '') return false
if (/^https?:\/\//i.test(trimmed)) return true
return /^\$[0-9]/.test(trimmed)
}
/** Keep only the rules that can actually run. */
export function compileLinkRules(rules: readonly LinkRule[] | undefined): CompiledLinkRule[] {
if (!rules) return []
const out: CompiledLinkRule[] = []
for (const rule of rules) {
if (rule.enabled === false) continue
if (rule.pattern.trim() === '' || rule.template.trim() === '') continue
try {
out.push({ id: rule.id, regex: new RegExp(rule.pattern, 'g'), template: rule.template })
} catch {
// sanitizeLinkRules already dropped these; a hand-edited settings.json or
// a rule list built in memory could still carry one.
}
}
return out
}
/**
* Every rule-made link on one logical line, in rule order (an earlier rule
* claims its span first, like the highlight engine) and non-overlapping.
* Targets failing the scheme gate are dropped without a sound.
*/
export function findRuleLinks(text: string, rules: readonly CompiledLinkRule[]): LinkSpan[] {
if (rules.length === 0 || text === '' || text.length > LINK_TEXT_MAX) return []
const out: LinkSpan[] = []
for (const rule of rules) {
const claimed: LinkSpan[] = []
rule.regex.lastIndex = 0
let match: RegExpExecArray | null
while ((match = rule.regex.exec(text)) !== null) {
// A pattern that can match the empty string would spin forever on the
// same index: step past it instead of trusting lastIndex to advance.
if (match[0] === '') {
rule.regex.lastIndex = match.index + 1
continue
}
if (claimed.length >= MAX_PER_RULE) break
const start = match.index
const end = start + match[0].length
if (overlaps(out, start, end) || overlaps(claimed, start, end)) continue
const url = formatLinkTemplate(rule.template, match).trim()
if (!isSafeLinkTarget(url)) continue
const span = { start, end, url }
claimed.push(span)
out.push(span)
}
}
return out
}
function overlaps(spans: readonly LinkSpan[], start: number, end: number): boolean {
return spans.some((span) => start < span.end && end > span.start)
}
/**
* Combine the two link sources into one non-overlapping list: the primary
* (user rules — a deliberate rule beats a heuristic) keeps every span it
* claimed, the secondary (built-in URL detection) contributes only what does
* not collide. Sorted by start, which is what the provider maps to cells.
*/
export function mergeLinkSpans(
primary: readonly LinkSpan[],
secondary: readonly LinkSpan[]
): LinkSpan[] {
const kept: LinkSpan[] = [...primary]
for (const span of secondary) {
if (!overlaps(primary, span.start, span.end)) kept.push(span)
}
return kept.sort((a, b) => a.start - b.start || b.end - a.end)
}
+104 -2
View File
@@ -36,6 +36,19 @@ export interface HighlightRule {
bands?: { min: number; fg: string }[]
/** match without regard to letter case (compiled with the 'i' flag) */
caseInsensitive?: boolean
/**
* Anchor `^` / `$` per line (compiled with the 'm' flag). Without it a pattern
* like `^ERROR$` is anchored to the whole text run, which never matches in
* multi-line output — the run spans every line the program printed.
*/
lineAnchor?: boolean
/**
* Raise an in-app alert when this rule paints a span (per-rule "notify on
* hit"). The engine only reports the event (`MatchListener`); the terminal
* decides whether to show anything, so a rule carrying this flag still
* highlights exactly the same way when nothing consumes the event.
*/
notify?: boolean
/**
* Part of the "basic" set: still applied when the highlight mode is
* `basic` (the cheap, low-noise subset — status, danger, secrets).
@@ -50,6 +63,13 @@ export interface HighlightRule {
export interface TerminalSettings {
fontFamily: string
/**
* CJK fallback family appended to the tail of `fontFamily`'s stack. An empty
* string appends nothing (unchanged behaviour); non-empty means the glyphs
* the primary faces lack — wide CJK, and the emoji/box-drawing they drag
* along — resolve here instead of whatever the last-resort system font picks.
*/
cjkFontFamily?: string
fontSize: number
/** 100..900 */
fontWeight: number
@@ -151,6 +171,63 @@ export function isLockAutoDelay(value: unknown): value is LockAutoDelay {
return LOCK_AUTO_DELAYS.includes(value as LockAutoDelay)
}
/**
* Legal ranges for the numeric terminal-font settings, shared by the settings UI,
* the main-process sanitizer and the font hotkeys so they cannot disagree.
*
* `lineHeight` starts at **1**, not 0.8: xterm throws
* "lineHeight cannot be less than 1" from the Terminal constructor, and that
* throw happened inside a dockview pane — the error boundary unmounted the whole
* pane, and because the bad value had already been persisted the app came back
* broken on every restart.
*/
export const FONT_LIMITS = {
fontSize: { min: 8, max: 40 },
lineHeight: { min: 1, max: 2 },
letterSpacing: { min: 0, max: 10 },
/** xterm throws "scrollback cannot be less than 0"; the UI already floors it at 100. */
scrollback: { min: 100, max: 100000 }
} as const
/** Clamp one numeric font setting into its legal range; non-finite → fallback. */
export function clampFontSetting(key: keyof typeof FONT_LIMITS, value: unknown, fallback: number): number {
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
const { min, max } = FONT_LIMITS[key]
return Math.min(max, Math.max(min, n))
}
/**
* xterm's own whitelists. Assigning an unrecognised value to a *live* terminal
* (`term.options.x = v`, the settings hot-update path) THROWS with no try/catch
* around it, so a single bad string unmounts the pane. Worse, the cursor option
* is swallowed during construction, so the pane's "retry" button re-crashes
* forever. Keep these in sync with xterm's OptionsService.
*/
export const CURSOR_STYLES = ['block', 'underline', 'bar'] as const
export const CURSOR_INACTIVE_STYLES = ['outline', 'block', 'bar', 'underline', 'none'] as const
export const RENDERER_MODES = ['auto', 'webgl', 'dom'] as const
export function pickCursorStyle(value: unknown, fallback: (typeof CURSOR_STYLES)[number]) {
return CURSOR_STYLES.includes(value as (typeof CURSOR_STYLES)[number])
? (value as (typeof CURSOR_STYLES)[number])
: fallback
}
export function pickCursorInactiveStyle(
value: unknown,
fallback: (typeof CURSOR_INACTIVE_STYLES)[number]
) {
return CURSOR_INACTIVE_STYLES.includes(value as (typeof CURSOR_INACTIVE_STYLES)[number])
? (value as (typeof CURSOR_INACTIVE_STYLES)[number])
: fallback
}
export function pickRendererMode(value: unknown, fallback: (typeof RENDERER_MODES)[number]) {
return RENDERER_MODES.includes(value as (typeof RENDERER_MODES)[number])
? (value as (typeof RENDERER_MODES)[number])
: fallback
}
/**
* Screen-lock preferences. The password itself is never stored here — the
* verifier lives in `<userData>/lock.json` (src/main/lockStore.ts), so settings
@@ -230,6 +307,22 @@ export interface HighlightProfile {
ruleIds: string[]
}
/**
* A user-defined clickable link rule: a regex over a terminal line plus a URL
* template expanded with its capture groups (`$0` whole match, `$1`–`$9`
* groups). Validation and template expansion live in `linkRules.ts`.
*/
export interface LinkRule {
id: string
/** regex source, compiled with the 'g' flag at use site */
pattern: string
/** URL template; must expand to an http(s) target to be clickable */
template: string
/** absent means enabled */
enabled?: boolean
note?: string
}
export interface AppSettings {
terminal: TerminalSettings
/** user-created themes; builtin themes are immutable and live in shared/theme.ts */
@@ -238,6 +331,8 @@ export interface AppSettings {
highlightRules: HighlightRule[]
/** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */
highlightProfiles: HighlightProfile[]
/** user-defined clickable link rules (see shared/linkRules.ts); absent = none */
linkRules?: LinkRule[]
system: SystemSettings
/** screen lock; the password verifier lives outside settings (lock.json) */
lock: LockSettings
@@ -254,6 +349,7 @@ const RULE = (
caseInsensitive?: boolean
bands?: HighlightRule['bands']
basic?: boolean
notify?: boolean
}
): HighlightRule => ({
id,
@@ -266,6 +362,7 @@ const RULE = (
...(opts?.caseInsensitive ? { caseInsensitive: true } : {}),
...(opts?.bands ? { bands: opts.bands } : {}),
...(opts?.basic ? { basic: true } : {}),
...(opts?.notify ? { notify: true } : {}),
builtin: true
})
@@ -296,7 +393,7 @@ const RULE = (
* which is what makes a progress bar readable at a glance.
*/
export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
RULE('perm', '^[-dlbcps][rwxstST-]{9}', 1, '#3fb950', 'Linux 权限与用户'),
RULE('perm', '(?<=^|\\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])', 1, '#3fb950', 'Linux 权限与用户'),
RULE('path', '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)', 2, '#58a6ff', 'Linux 文件路径'),
RULE(
'danger',
@@ -418,7 +515,10 @@ export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
'\\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,})\\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\\bBearer\\s+[A-Za-z0-9._-]{16,}',
31,
'#ff7b72',
'疑似密钥/令牌'
'疑似密钥/令牌',
// The one preset that alerts on a hit: a leaked key scrolling past is
// exactly what a terminal should interrupt you for.
{ notify: true }
)
]
@@ -467,6 +567,7 @@ for (const rule of DEFAULT_HIGHLIGHT_RULES) {
export const DEFAULT_SETTINGS: AppSettings = {
terminal: {
fontFamily: "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace",
cjkFontFamily: '',
fontSize: 14,
fontWeight: 400,
boldFontWeight: 700,
@@ -501,6 +602,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
highlightProfiles: [],
linkRules: [],
// Every 4 hours by default: often enough for a terminal app that runs all day,
// and the download stays manual (autoDownloadUpdate) until the user opts in.
system: {
+152
View File
@@ -0,0 +1,152 @@
/**
* Font-family quoting self-test (font-stack.mjs).
*
* The font enumerator hands back BARE family names (`disableQuoting: true`), and
* the picked name is stored verbatim in `settings.terminal.fontFamily` — one name
* or a hand-written stack. Passing that straight to xterm's `fontFamily` or to the
* settings preview's inline style only works because parsers are lenient: a name
* with `(` `)` `+`, one starting with a digit, or one that collides with a CSS
* keyword is not a valid CSS family. `quoteFontStack` fixes that at the consumer
* sites, so the contract pinned here is:
*
* - plain identifiers and the family keywords stay bare (quoting the keywords
* would turn `monospace` into a font literally named "monospace");
* - anything else is double-quoted, inner double quotes escaped;
* - splitting is on TOP-LEVEL commas only — a comma inside `'Name, with comma'`
* or `"Name"` is part of that family, not a separator;
* - already-quoted segments survive untouched, which makes the function
* idempotent: second pass over already-processed output is a no-op;
* - empty/blank input is the empty string (not `"\"\""`), so a cleared setting
* does not become a font family named "".
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and no
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/font-stack.mjs (must exit 0)
*/
import { quoteFontStack } from '../src/renderer/src/terminal/fontStack.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** Table-driven: [input, expected]. */
const table = (label, cases) => {
console.log(label)
for (const [input, expected] of cases) {
const got = quoteFontStack(input)
ok(
got === expected,
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
)
}
}
table('plain identifiers and keywords are left alone', [
['Consolas', 'Consolas'],
['JetBrainsMono', 'JetBrainsMono'],
['DejaVu_Sans', 'DejaVu_Sans'],
['-apple-system', '-apple-system'],
['monospace', 'monospace'],
['serif', 'serif'],
['sans-serif', 'sans-serif'],
['cursive', 'cursive'],
['fantasy', 'fantasy'],
['system-ui', 'system-ui'],
['ui-monospace', 'ui-monospace'],
['default', 'default'],
['inherit', 'inherit'],
['initial', 'initial'],
['unset', 'unset'],
['Monospace', 'Monospace'],
// a keyword mixed into a stack stays bare while the neighbours are judged apart
['Consolas,monospace', 'Consolas, monospace']
])
table('names CSS cannot parse bare get double quotes', [
['Cascadia Code (Preview)', '"Cascadia Code (Preview)"'],
['Fira Code + Ligatures', '"Fira Code + Ligatures"'],
['4uto', '"4uto"'],
['.SF NS Mono', '".SF NS Mono"'],
['微軟雅黑', '"微軟雅黑"'],
['微软雅黑', '"微软雅黑"'],
['MS ゴシック', '"MS ゴシック"'],
['My Font!', '"My Font!"'],
['IntelliJ IDEA-like', '"IntelliJ IDEA-like"'],
['name{with}braces', '"name{with}braces"']
])
table('existing quoting is preserved', [
["'JetBrains Mono'", "'JetBrains Mono'"],
['"JetBrains Mono"', '"JetBrains Mono"'],
["Consolas, 'Fira Code', monospace", 'Consolas, \'Fira Code\', monospace'],
['"Noto Sans CJK SC", sans-serif', '"Noto Sans CJK SC", sans-serif']
])
table('only top-level commas separate families', [
["'Name, with comma'", "'Name, with comma'"],
['"Name, with comma", monospace', '"Name, with comma", monospace'],
['"A, B"', '"A, B"'],
["'A, B', 'C, D'", "'A, B', 'C, D'"],
// unquoted commas are separators, so this is two families, not one
['Name, with comma', 'Name, "with comma"']
])
table('segments are trimmed and empty ones dropped', [
[' Consolas , monospace ', 'Consolas, monospace'],
['Consolas,,monospace', 'Consolas, monospace'],
[',Consolas,', 'Consolas'],
['Consolas , , monospace', 'Consolas, monospace'],
[' Cascadia Code (Preview) ', '"Cascadia Code (Preview)"'],
['', ''],
[' ', ''],
[',', ''],
[' , , ', '']
])
table('inner double quotes are escaped, not nested', [
['Say "Hi"', '"Say \\"Hi\\""'],
['"Already" trailing', '"\\"Already\\" trailing"']
])
// Backslashes must be escaped too: escaping only `"` turned a name with a
// trailing backslash into `"a\"`, whose closing quote is itself escaped — the
// CSS string never terminates and the font silently falls back to the browser
// default. Every produced segment must therefore end with an EVEN run of
// backslashes before its closing quote.
table('backslashes are escaped so the CSS string stays terminated', [
['a\\', '"a\\\\"'],
['\\', '"\\\\"'],
['Font Name\\', '"Font Name\\\\"'],
['x\\"; color:red', '"x\\\\\\"; color:red"']
])
console.log('quoting is idempotent')
const stacks = [
'Consolas',
'Cascadia Code (Preview)',
'Consolas, Cascadia Code (Preview), monospace',
"Consolas, 'Fira Code', monospace",
'"Name, with comma", monospace',
'微软雅黑',
''
]
for (const stack of stacks) {
const once = quoteFontStack(stack)
ok(
quoteFontStack(once) === once,
`${JSON.stringify(stack)} survives a second pass (${JSON.stringify(once)})`
)
}
console.log('the stored setting is the caller\'s problem (input is never mutated)')
const original = 'Cascadia Code (Preview)'
quoteFontStack(original)
ok(original === 'Cascadia Code (Preview)', 'the input string is untouched')
if (failed > 0) {
console.error(`\n[font-stack] ${failed} check(s) FAILED`)
}
console.log('\n[font-stack] ALL CHECKS PASSED')
process.exit(failed ? 1 : 0)
+404 -2
View File
@@ -12,7 +12,7 @@
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { compileRules, applyHighlights, HighlightStream, __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 {
@@ -807,5 +807,407 @@ console.log('[escape sequences]')
ok(hooks.findIncompleteIndex('DONE \x1b7') === -1, 'a trailing DECSC is not held as incomplete')
}
// ---- 20. linux permission strings (ls -l) ----------------------------------
console.log('[permissions]')
{
// A multi-line ls -l dump arrives as one text run: every line's mode string
// must light up, not just the first. The old `^` pattern anchored the run
// start, so line two and down came back uncoloured.
const lsDump = '-rw-r--r-- 1 root root 10 x\n-rwxr-xr-x 1 root root 20 y\n'
ok(colored(lsDump, OK_GREEN, '-rw-r--r--'), 'the first line\'s mode string highlights')
ok(
colored(lsDump, OK_GREEN, '-rwxr-xr-x'),
'the second line\'s mode string highlights too (no run-start anchoring)'
)
// A coloured prompt cuts the run at the escape, so the mode string no longer
// starts the run: the lookbehind must accept the surrounding space instead.
ok(
colored('\x1b[32m$\x1b[0m -rw-r--r-- 1 a b\n', OK_GREEN, '-rw-r--r--'),
'a mode string right after an escape sequence highlights'
)
// ...but glued to a word it is not a permission string at all.
ok(
!colored('x-rw-r--r--y something', OK_GREEN, '-rw-r--r--'),
'a non-space character in front of the mode string keeps it plain'
)
// and a longer-than-10 mode string must not be clipped to its first ten
// characters and highlighted.
ok(
!applyHighlights('drwxr-xr-xr-x file', rules).includes(`${sgr(OK_GREEN)}drwxr-xr-x\x1b[0m`),
'an eleven-character mode string is not trimmed and highlighted'
)
// ACL/SELinux '+' and macOS '@' attribute markers must not break the match:
// the trailing boundary only rejects another mode character, not markers.
ok(
colored('-rw-r--r--+ 1 bill staff 512 Oct 17 10:00 acl.txt', OK_GREEN, '-rw-r--r--'),
'an ACL "+" suffix after the mode string still highlights'
)
ok(
colored('-rw-r--r--@ 1 bill staff 512 Oct 17 10:00 xattr.txt', OK_GREEN, '-rw-r--r--'),
'a macOS "@" suffix after the mode string still highlights'
)
}
// ---- 21. a span must not clear the application's own SGR -------------------
console.log('[sgr restore]')
{
// `wrap` closes every span with a reset. Before the restore, that reset also
// wiped whatever SGR the *application* had set, so the tail of a yellow
// warning came back in the default colour.
const yellow = applyHighlights('\x1b[33mWARNING tail here', rules)
ok(
yellow.includes(`${sgr(WARN_YELLOW)}WARNING\x1b[0m\x1b[33m tail here`),
'the application\u2019s yellow is replayed after the span, not just a bare reset'
)
ok(
!yellow.endsWith('WARNING\x1b[0m tail here'),
'the span no longer leaves the text after it in the default colour'
)
// consecutive SGRs are replayed as a set, in order: dropping the bold would
// silently un-bold everything after the span.
const bold = applyHighlights('\x1b[1m\x1b[31mERR rest', rules)
ok(
bold.includes(`${sgr(BAD_RED)}ERR\x1b[0m\x1b[1m\x1b[31m rest`),
'consecutive SGRs are all replayed, in their original order'
)
// no SGR in front of the run: unchanged, plain reset (the pre-existing shape)
const plain = applyHighlights('ERROR rest', rules)
ok(
plain === `${sgr(BAD_RED)}ERROR\x1b[0m rest`,
'a run with no preceding SGR still closes with a bare reset'
)
// a reset inside the chunk clears the state: only the SGRs after it replay
ok(
applyHighlights('\x1b[33m\x1b[0mWARNING tail', rules) ===
`\x1b[33m\x1b[0m${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'an SGR reset before the run clears what would be replayed'
)
// non-SGR sequences are never replayed — replaying a cursor move would move
// the cursor a second time on every span.
const cursor = applyHighlights('\x1b[2AWARNING tail', rules)
ok(
cursor === `\x1b[2A${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'non-SGR sequences (cursor moves) are passed through but never replayed'
)
ok(
applyHighlights('\x1b]0;title\x07WARNING tail', rules) ===
`\x1b]0;title\x07${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'an OSC title is passed through but never replayed'
)
// Every span in a run restores, not just the first — but the gap between two
// spans now carries the restore instead of the preceding span's tail, so that
// a dense line pays for the restore once per VISIBLE gap. Both spellings put
// the application's yellow back between `WARNING` and `ERROR`.
const two = applyHighlights('\x1b[33mWARNING ERROR tail', rules)
ok(
two.startsWith(`\x1b[33m${sgr(WARN_YELLOW)}WARNING\x1b[0m`),
'the first span keeps its highlight'
)
ok(
two.includes(`${sgr(BAD_RED)}ERROR\x1b[0m\x1b[33m tail`),
'the last span replays the active SGR before the trailing text'
)
// the state survives across chunks: a colour set in one push must still be
// restored by a span that lands in a later one, or split output would differ
// from single-chunk output.
const stream = new HighlightStream(rules)
const streamed = stream.push('\x1b[33m') + stream.push('WARNING tail here') + stream.flush()
ok(
streamed === applyHighlights('\x1b[33mWARNING tail here', rules),
'the SGR state carries across stream pushes (split == single chunk)'
)
// a run split by an escape sequence inside the span keeps the known shape:
// the text before the escape is one run, the text after it another, and only
// the first is coloured.
ok(
applyHighlights('ERR\x1b[0mOR', rules) === `${sgr(BAD_RED)}ERR\x1b[0m\x1b[0mOR`,
'an escape in the middle of a word still cuts the run (only the first half colours)'
)
}
// ---- 22. an over-long line only costs itself -------------------------------
console.log('[long lines]')
{
const LONG = 'A'.repeat(5000)
// regression: one base64 blob used to switch highlighting off for the whole
// run, so the ERROR on the next line came back uncoloured.
const mixed = applyHighlights(`${LONG} ERROR\nERROR still here`, rules)
ok(
mixed === `${LONG} ERROR\n${sgr(BAD_RED)}ERROR\x1b[0m still here`,
'a 5KB line passes through raw while the next line still highlights'
)
// ...and the same holds when the long line is in the middle or at the end
ok(
applyHighlights(`ok\n${LONG}\nerror here`, rules).endsWith(`\n${LONG}\n${sgr(BAD_RED)}error\x1b[0m here`),
'a long line in the middle does not stop the line after it from colouring'
)
ok(
applyHighlights(`ERROR\n${LONG}`, rules) === `${sgr(BAD_RED)}ERROR\x1b[0m\n${LONG}`,
'a long line at the end does not stop the line before it from colouring'
)
// every line over the bound: the run comes back untouched
ok(
applyHighlights(`${LONG}\n${LONG}`, rules) === `${LONG}\n${LONG}`,
'a run whose every line is over-long is returned byte-for-byte unchanged'
)
// the bound is exactly MAX_LINE_LEN (4096) per line, terminator included in
// the count: a line of exactly 4096 is still scanned, 4097 goes through raw.
const atBound = `${'E'.repeat(4096 - ' ERROR'.length)} ERROR`
ok(atBound.length === 4096, 'the boundary sample is exactly 4096 characters')
ok(
applyHighlights(atBound, rules).endsWith(`${sgr(BAD_RED)}ERROR\x1b[0m`),
'a line exactly at the 4KB bound is still scanned'
)
const overBound = `E${atBound}`
ok(
applyHighlights(overBound, rules) === overBound,
'a line one byte over the 4KB bound goes through raw'
)
// no over-long line → one whole-run scan, so rules keep matching across a
// newline (the path rule claims the leading "\n" of the second line).
ok(
applyHighlights('see the file\n/tmp/x here', rules).includes(`${sgr('#58a6ff')}\n/tmp/x\x1b[0m`),
'a run with no over-long line still matches across a newline'
)
// budgets stay shared across the degraded per-line scan: the long line does
// not hand each short line a fresh allowance
const sink = new Map()
applyHighlights(`${LONG}\nERROR\nERROR\nERROR`, rules, sink)
ok(
sink.get('badstate')?.hits === 3,
`the per-rule budget and hit count span every line of a degraded run (hits=${sink.get('badstate')?.hits})`
)
}
// ---- 23. per-line anchoring (user "lineAnchor" switch) ---------------------
console.log('[line anchor]')
{
const TEXT = 'a\nERROR\nERROR b\n'
const plain = compileRules([
{ id: 'anch', pattern: '^ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
const perLine = compileRules([
{
id: 'anch',
pattern: '^ERROR$',
enabled: true,
priority: 1,
color: { fg: BAD_RED },
lineAnchor: true
}
])
// Without 'm' the anchors bind to the whole run: `a\nERROR\nERROR b\n` neither
// starts nor ends with ERROR, so nothing matches at all.
ok(
applyHighlights(TEXT, plain) === TEXT,
'without lineAnchor, ^ERROR$ does not match inside a multi-line run'
)
// With 'm' each line is its own anchor: only the bare ERROR line matches —
// `ERROR b` is not followed by end-of-line.
const out = applyHighlights(TEXT, perLine)
ok(
out === `a\n${sgr(BAD_RED)}ERROR\x1b[0m\nERROR b\n`,
'lineAnchor makes ^ERROR$ match the standalone ERROR line only'
)
ok(
!out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m b`),
'lineAnchor still does not claim a longer line (ERROR b)'
)
// Anchoring only moves the boundaries: '$' works the same way.
const tail = compileRules([
{ id: 'tail', pattern: 'ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED }, lineAnchor: true }
])
const tailOut = applyHighlights(TEXT, tail)
ok(
tailOut.includes(`${sgr(BAD_RED)}ERROR\x1b[0m\n`) && tailOut.includes('ERROR b\n') &&
!tailOut.includes(`${sgr(BAD_RED)}ERROR b`),
'lineAnchor anchors $ at each line end as well'
)
// The two flags compose: 'i' and 'm' both apply when both switches are on.
const both = compileRules([
{
id: 'both',
pattern: '^error$',
enabled: true,
priority: 1,
color: { fg: BAD_RED },
caseInsensitive: true,
lineAnchor: true
}
])
ok(
applyHighlights(TEXT, both) === `a\n${sgr(BAD_RED)}ERROR\x1b[0m\nERROR b\n`,
'lineAnchor composes with caseInsensitive (gim flags)'
)
}
// ---- 24. match events (P2-1: `onMatch` outlet) ------------------------------
console.log('[match events]')
{
// Only spans that actually get painted are reported, and each carries the
// rule id plus the raw matched text.
const events = []
const one = compileRules([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
])
const out = applyHighlights('all OK here', one, undefined, undefined, (id, text) =>
events.push([id, text])
)
ok(
out === `all ${sgr(OK_GREEN)}OK\x1b[0m here`,
'onMatch does not change the highlighted output'
)
ok(
events.length === 1 && events[0][0] === 'ok' && events[0][1] === 'OK',
`one painted span reports (ruleId, text) (got ${JSON.stringify(events)})`
)
}
{
// A match a higher-priority rule claimed first never paints, so it must not
// report either — otherwise a consumer would alert about colour that is not
// on screen. `done` is the documented case: shellkw beats status words.
const events = []
const overlap = compileRules([
{ id: 'first', pattern: '\\bdone\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } },
{ id: 'second', pattern: '\\b(done|DONE)\\b', enabled: true, priority: 2, color: { fg: BAD_RED } }
])
applyHighlights('job done', overlap, undefined, undefined, (id, text) => events.push([id, text]))
ok(
events.length === 1 && events[0][0] === 'first',
`a span stolen by a higher-priority rule reports nothing (got ${JSON.stringify(events)})`
)
}
{
// The per-rule budget is the other silent case: a rule that hits MAX_PER_RULE
// (300) times in one run reports exactly that many times, not one more.
let count = 0
const many = compileRules([
{ id: 'x', pattern: 'ERROR', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
applyHighlights(Array.from({ length: 301 }, () => 'ERROR').join(' '), many, undefined, undefined, () => {
count++
})
ok(count === 300, `matches past the per-rule budget stop reporting (reported ${count})`)
}
{
// Regression: passing no listener must produce byte-identical output.
const plain = compileRules(DEFAULT_HIGHLIGHT_RULES)
const sample =
'ERROR: rm -rf /tmp/x\nOK 45% http://example.com\nperm -rwxr-xr-x 2026-10-17 12:34\n'
const withHook = applyHighlights(sample, plain, undefined, undefined, () => undefined)
ok(applyHighlights(sample, plain) === withHook, 'a no-op listener leaves the bytes untouched')
}
{
// Stream: the listener is threaded through every push path, so a match that
// only completes once the held carry is flushed still reports exactly once.
const events = []
const stream = new HighlightStream(
compileRules([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
]),
(id, text) => events.push([id, text])
)
let out = ''
for (const piece of ['all ', 'OK', ' here\n']) out += stream.push(piece)
out += stream.flush()
ok(
out === `all ${sgr(OK_GREEN)}OK\x1b[0m here\n`,
'split pushes still produce the whole-run output'
)
ok(
events.length === 1 && events[0][1] === 'OK',
`a match split across chunks reports once (got ${JSON.stringify(events)})`
)
}
{
// Rules without the flag are irrelevant to the engine: it reports every painted
// span and leaves the notify policy to the consumer.
const events = []
const mixed = compileRules([
{ id: 'silent', pattern: '\\bWARN\\b', enabled: true, priority: 1, color: { fg: WARN_YELLOW } }
])
applyHighlights('WARN', mixed, undefined, undefined, (id) => events.push(id))
ok(events.length === 1 && events[0] === 'silent', 'the engine reports un-flagged rules too')
}
console.log('[restore cost budget]')
{
// Replaying the app's SGR state after EVERY span costs (state length x spans).
// A dense truecolor TUI line (Kimi-style: ~19 spans) made the engine inflate
// a 9.5KB chunk to 40.8KB — 4.30x, versus 2.78x before the restore existed.
// Downstream xterm could not keep up and the renderer froze for 18.4s. The
// budget is deliberately expressed as a size ratio over a big chunk so that
// raising MAX_REPLAY_COST fails HERE rather than in a user's terminal.
const tuiLine = (i) =>
'\x1b[38;2;120;130;140m' + `item ${i} 68% 2026-10-17T18:30:00 /src/a/b/c.ts:42:7 ERROR ` + '\x1b[0m\n'
const dense = Array.from({ length: 120 }, (_, i) => tuiLine(i)).join('')
const denseOut = applyHighlights(dense, rules)
const ratio = denseOut.length / dense.length
ok(ratio < 3.2, `dense truecolor TUI stays within budget (${ratio.toFixed(2)}x < 3.2x; regression was 4.30x)`)
// The budget must not kill the case it exists for: a single-colour state is
// short enough to replay, so the app's colour survives the span.
const yellow = applyHighlights('\x1b[33mWARNING tail here\x1b[0m\n', rules)
ok(yellow.length > '\x1b[33mWARNING tail here\x1b[0m\n'.length, 'the matched word still gets its highlight')
ok(
yellow.slice(yellow.indexOf('WARNING') + 'WARNING'.length).includes('\x1b[33m'),
'and the application\'s own yellow is replayed after the span (short state still worth it)'
)
}
console.log('[reset spellings and CSI parameters]')
{
// ECMA-48 has many spellings of "reset everything". Recognising only the two
// canonical ones let the other spellings be APPENDED to the SGR accumulator,
// so it grew a few bytes per line until it blew the cap and every later span
// lost its restore. Go/Java log libraries routinely emit ESC[0;32m.
const warn = compileRules([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }])
const tailAfter = (input) => {
const out = applyHighlights(input, warn)
return out.slice(out.indexOf('WARNING') + 'WARNING'.length)
}
for (const [label, reset, want] of [
['ESC[0m', '\x1b[0m', '\x1b[32m'],
['ESC[00m', '\x1b[00m', '\x1b[32m'],
['ESC[0;0m', '\x1b[0;0m', '\x1b[32m'],
['ESC[0;32m', '\x1b[0;32m', '\x1b[0;32m']
]) {
const tail = tailAfter(`\x1b[33mA${reset}\x1b[32mWARNING tail`)
ok(tail.includes(want), `${label} clears the accumulated state (tail keeps ${JSON.stringify(want)})`)
}
// CSI parameter bytes include the private prefixes `<`, `>`, `=` and the
// intermediate bytes ` -/`. Without them the whole line was treated as text
// containing a raw ESC, so every keyword after one lost its highlight — and
// these sequences are exactly what vim/tmux emit for mouse and key reporting.
for (const [label, seq] of [
['ESC[>4;1m (modifyOtherKeys)', '\x1b[>4;1m'],
['ESC[<0;1;2M (SGR mouse)', '\x1b[<0;1;2M'],
['ESC[=1;1$y (DECRQM)', '\x1b[=1;1$y']
]) {
ok(applyHighlights(`${seq}WARNING after\n`, warn).includes('255;0;0'), `${label} keeps the rest of the line highlighted`)
}
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
process.exit(failed === 0 ? 0 : 1)
+333
View File
@@ -0,0 +1,333 @@
/**
* Clickable link rule self-test (link-rules.mjs).
*
* `src/shared/linkRules.ts` is the whole engine behind "regex hits terminal
* output → the match becomes a clickable link": validation on load, template
* expansion, the scheme gate and the span merge that keeps user rules and the
* built-in URL heuristics from overlapping.
*
* The tables below pin behaviour that is easy to break silently:
*
* - sanitize drops what cannot work (no id, an uncompilable regex, an empty
* template, a duplicate id) and keeps what merely defaults (`enabled`
* absent = on);
* - `formatLinkTemplate` expands `$0`–`$9` and `$$`, leaves a `$` before
* anything else alone, and consumes only ONE digit (`$10` = group 1 + "0");
* - a missing / non-participating group expands to nothing rather than the
* literal "undefined";
* - the scheme gate rejects `file:`, `javascript:`, `ms-settings:` and
* anything unparsable — a template is user input and must never hand an
* arbitrary handler to the OS;
* - `findRuleLinks` claims earlier rules first and never emits overlapping
* ranges, including a pattern that can match the empty string;
* - `mergeLinkSpans` keeps the user rule and drops the colliding built-in.
*
* Direct .ts import (no esbuild bundle): the module has no imports and no
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/link-rules.mjs (must exit 0)
*/
import {
LINK_NOTE_MAX,
LINK_PATTERN_MAX,
LINK_RULES_MAX,
LINK_TEMPLATE_MAX,
LINK_TEXT_MAX,
compileLinkRules,
formatLinkTemplate,
findRuleLinks,
isSafeLinkTarget,
mergeLinkSpans,
sanitizeLinkRules,
isSafeLinkTemplate
} from '../src/shared/linkRules.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const raw = (over) => ({ id: 'r1', pattern: '\\b(\\d+)\\b', template: 'https://x/$1', ...over })
// ---- sanitize ---------------------------------------------------------------
console.log('[sanitize]')
{
const out = sanitizeLinkRules([raw()])
ok(out.length === 1, 'a well-formed rule is kept')
ok(out[0].enabled === true, 'an absent enabled flag defaults to true')
ok(out[0].note === undefined, 'an absent note stays undefined (not "")')
}
{
const out = sanitizeLinkRules([raw({ enabled: false })])
ok(out[0].enabled === false, 'enabled: false survives')
}
{
const out = sanitizeLinkRules([raw({ note: ' build id ' })])
ok(out[0].note === 'build id', 'the note is trimmed')
}
{
const long = 'n'.repeat(LINK_NOTE_MAX + 20)
const out = sanitizeLinkRules([raw({ note: long })])
ok(out[0].note.length === LINK_NOTE_MAX, `a long note is truncated to ${LINK_NOTE_MAX}`)
}
const dropped = [
['a non-array value', 'nope'],
['null', null],
['an array of scalars', ['x', 1, null]],
['a missing id', [raw({ id: undefined })]],
['an empty id', [raw({ id: '' })]],
['a numeric id', [raw({ id: 7 })]],
['a missing pattern', [raw({ pattern: undefined })]],
['an empty pattern', [raw({ pattern: '' })]],
['a whitespace-only pattern', [raw({ pattern: ' ' })]],
['an uncompilable regex', [raw({ pattern: '(unclosed' })]],
['a missing template', [raw({ template: undefined })]],
['an empty template', [raw({ template: '' })]],
['a whitespace-only template', [raw({ template: ' ' })]],
['an over-long pattern', [raw({ pattern: 'a'.repeat(LINK_PATTERN_MAX + 1) })]]
]
for (const [label, value] of dropped) {
const out = sanitizeLinkRules(value)
ok(out.length === 0, `${label} yields no rules (got ${JSON.stringify(out)})`)
}
{
const warnings = []
sanitizeLinkRules([raw(), raw()], (m) => warnings.push(m))
// two identical entries share id 'r1': the second is a duplicate
const out = sanitizeLinkRules([raw(), raw(), raw({ id: 'r2' })])
ok(out.length === 2 && out[1].id === 'r2', 'a duplicate id is dropped, later distinct ids survive')
ok(warnings.length >= 1, 'the warn callback receives a message for each dropped entry')
}
{
const many = Array.from({ length: LINK_RULES_MAX + 5 }, (_, i) => raw({ id: `r${i}` }))
const out = sanitizeLinkRules(many)
ok(out.length === LINK_RULES_MAX, `no more than ${LINK_RULES_MAX} rules are kept (got ${out.length})`)
}
{
// an unparsable regex would otherwise throw on every line the terminal draws
const out = sanitizeLinkRules([raw({ pattern: 'a{2,1}' })])
ok(out.length === 0, 'a regex with an invalid quantifier range is dropped')
}
// ---- formatLinkTemplate -----------------------------------------------------
console.log('[formatLinkTemplate]')
const expand = (template, source, pattern) =>
formatLinkTemplate(template, new RegExp(pattern, 'g').exec(source))
const formatCases = [
['$0 is the whole match', 'https://x/$0', 'abc 123', '\\d+', 'https://x/123'],
['$1 is the first group', 'https://x/$1', 'abc 123', '(\\d+)', 'https://x/123'],
['$2 is the second group', 'https://x/$2/$1', 'k=v', '=(\\w+)', 'https://x//v'],
['$0 and $1 together', '$0|$1', 'ab12', '([a-z]+)(\\d+)', 'ab12|ab'],
['$9 is the ninth group', 'https://x/$9', '123456789', '(1)(2)(3)(4)(5)(6)(7)(8)(9)', 'https://x/9'],
['a template with no placeholders is literal', 'https://x/static', 'abc', 'abc', 'https://x/static'],
['$$ is a literal $', 'https://x/a$$b', 'abc', 'abc', 'https://x/a$b'],
['a $ before a letter stays literal', 'https://x/$a', 'abc', 'abc', 'https://x/$a'],
['a trailing $ stays literal', 'https://x/$', 'abc', 'abc', 'https://x/$'],
['$10 reads group 1 then a literal 0', 'https://x/$10', 'ab', '([a-z])b', 'https://x/a0'],
[
'a non-participating group expands to nothing',
'https://x/$1$2',
'ab',
'(a)(b)?',
'https://x/ab'
]
]
for (const [label, template, source, pattern, expected] of formatCases) {
const got = expand(template, source, pattern)
ok(got === expected, `${label}: ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`)
}
{
// the "undefined" leak that made a $2 on a one-group match render as text
const got = expand('https://x/$2', 'abc', '(\\w+)')
ok(!got.includes('undefined'), 'a group the pattern does not have never renders "undefined"')
}
// ---- isSafeLinkTarget -------------------------------------------------------
console.log('[isSafeLinkTarget]')
const safe = ['https://example.com', 'http://localhost:5000/x', 'HTTPS://EXAMPLE.COM', ' https://a.b ']
for (const url of safe) ok(isSafeLinkTarget(url), `${JSON.stringify(url)} is allowed`)
const unsafe = [
'',
' ',
'file:///c:/windows',
'javascript:alert(1)',
'ms-settings:privacy',
'vscode://file/x',
'ftp://f.example.com',
'//example.com',
'/relative/path',
'example.com',
'mailto:user@example.com'
]
for (const url of unsafe) ok(!isSafeLinkTarget(url), `${JSON.stringify(url)} is refused`)
// ---- isSafeLinkTemplate -----------------------------------------------------
console.log('[isSafeLinkTemplate]')
const templateSafe = [
'https://example.com',
'http://localhost:5000/$1',
' https://a.b/$2 ',
'$0',
'$1',
'$9/x'
]
for (const template of templateSafe) {
ok(isSafeLinkTemplate(template), `template ${JSON.stringify(template)} is allowed`)
}
const templateUnsafe = [
'',
' ',
'file:///c:/windows',
'javascript:alert(1)',
'ms-settings:privacy',
'ftp://f.example.com/$1',
'/relative/$1',
'example.com/$1',
'www.example.com/$1'
]
for (const template of templateUnsafe) {
ok(!isSafeLinkTemplate(template), `template ${JSON.stringify(template)} is refused`)
}
// ---- compileLinkRules -------------------------------------------------------
console.log('[compileLinkRules]')
{
ok(compileLinkRules(undefined).length === 0, 'an absent rule list compiles to nothing')
ok(compileLinkRules([]).length === 0, 'an empty rule list compiles to nothing')
const compiled = compileLinkRules([
{ id: 'a', pattern: 'x', template: 'https://x/a' },
{ id: 'b', pattern: 'y', template: 'https://x/b', enabled: false },
{ id: 'c', pattern: 'z', template: 'https://x/c', enabled: true }
])
ok(compiled.length === 2, 'disabled rules are not compiled')
ok(compiled[0].regex.flags.includes('g'), 'the compiled regex is global')
}
// ---- findRuleLinks ----------------------------------------------------------
console.log('[findRuleLinks]')
const find = (text, rules) =>
findRuleLinks(text, compileLinkRules(rules)).map((s) => [s.start, s.end, s.url])
const ruleSet = [{ id: 't', pattern: 'BUG-(\\d+)', template: 'https://tracker/bug/$1' }]
{
ok(
JSON.stringify(find('see BUG-42 please', ruleSet)) ===
JSON.stringify([[4, 10, 'https://tracker/bug/42']]),
'a match becomes a range carrying the expanded target'
)
ok(
JSON.stringify(find('no match here', ruleSet)) === JSON.stringify([]),
'a line without a match yields no ranges'
)
ok(
JSON.stringify(find('BUG-1 BUG-2', ruleSet)) ===
JSON.stringify([
[0, 5, 'https://tracker/bug/1'],
[6, 11, 'https://tracker/bug/2']
]),
'two matches on one line keep their own offsets'
)
}
{
const unSafe = [{ id: 'f', pattern: 'FILE:(\\S+)', template: 'file://$1' }]
ok(
JSON.stringify(find('FILE:/c/windows', unSafe)) === JSON.stringify([]),
'a file:// template produces no range at all (it never becomes clickable)'
)
const js = [{ id: 'j', pattern: 'X(\\d+)', template: 'javascript:alert($1)' }]
ok(JSON.stringify(find('X1', js)) === JSON.stringify([]), 'a javascript: template is refused')
}
{
// no template → the matched text itself may be the target, as long as it is
// already a safe URL
const bare = [{ id: 'b', pattern: 'https?://\\S+', template: '$0' }]
ok(
JSON.stringify(find('open https://a.io/x now', bare)) ===
JSON.stringify([[5, 19, 'https://a.io/x']]),
'$0 with an https match is clickable'
)
}
{
const first = { id: '1', pattern: 'BUG-(\\d+)', template: 'https://one/$1' }
const second = { id: '2', pattern: 'BUG-\\d+', template: 'https://two/$0' }
ok(
JSON.stringify(find('BUG-7', [first, second])) === JSON.stringify([[0, 5, 'https://one/7']]),
'the earlier rule claims a span the later one may not overlap'
)
}
{
// an empty-matching pattern must not loop forever
const empty = [{ id: 'e', pattern: 'a*', template: 'https://x/$0' }]
const out = find('baaa', empty)
ok(out.length >= 1, 'a pattern that can match empty still yields the non-empty matches')
ok(
out.every(([start, end]) => end > start),
'no zero-width range is emitted'
)
}
{
const tooLong = 'x'.repeat(LINK_TEXT_MAX + 1) + 'BUG-1'
ok(
JSON.stringify(find(tooLong, ruleSet)) === JSON.stringify([]),
'a line past the length cap is not matched at all'
)
}
// ---- mergeLinkSpans ---------------------------------------------------------
console.log('[mergeLinkSpans]')
{
const user = [{ start: 0, end: 6, url: 'https://user/1' }]
const builtin = [{ start: 0, end: 5, url: 'http://builtin/1' }]
const merged = mergeLinkSpans(user, builtin)
ok(merged.length === 1 && merged[0].url === 'https://user/1', 'a user rule wins an overlapping built-in')
}
{
const user = [{ start: 0, end: 6, url: 'https://user/1' }]
const builtin = [{ start: 10, end: 20, url: 'http://builtin/2' }]
const merged = mergeLinkSpans(user, builtin)
ok(
merged.length === 2 && merged[0].start === 0 && merged[1].start === 10,
'disjoint ranges from both sources are all kept, sorted by start'
)
}
{
const merged = mergeLinkSpans([], [{ start: 5, end: 9, url: 'http://b' }])
ok(merged.length === 1, 'with no user rules the built-ins pass through unchanged')
ok(mergeLinkSpans([], []).length === 0, 'two empty sources merge to nothing')
ok(mergeLinkSpans([], []) !== mergeLinkSpans([], []), 'each call returns its own array')
}
{
// built-ins arrive from findUrls already sorted and non-overlapping; the merge
// must not disturb a long-scheme match that beat a shorter bare-host one
const merged = mergeLinkSpans([], [
{ start: 0, end: 21, url: 'http://localhost:5000' },
{ start: 22, end: 41, url: 'https://example.com' }
])
ok(merged.length === 2 && merged[0].end <= merged[1].start, 'merged ranges never overlap')
}
if (failed > 0) {
console.error(`\n[link-rules] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[link-rules] ALL CHECKS PASSED')
+20 -13
View File
@@ -10,12 +10,13 @@
* 2) the :root static fallback block declares exactly the emitted names;
* 3) both semanticVars() branches emit the same key set (a key that only
* exists on one branch leaves the other theme with a stale :root value).
* Also self-tests the kebab converter so a no-op can't make (1) vacuous.
* It also asserts the keys ARE the final CSS names (lowercase --ot-*), so the
* camelCase intermediate naming that broke v1.0.29 can never come back.
* Run: node tests/ot-var-names.mjs (must exit 0)
*/
import fs from 'node:fs'
import path from 'node:path'
import { cssVarName, semanticCssVars } from '../src/renderer/src/theme/semantic.ts'
import { semanticVars } from '../src/renderer/src/theme/semantic.ts'
let failed = 0
const ok = (cond, msg) => {
@@ -24,23 +25,29 @@ const ok = (cond, msg) => {
}
const ROOT = path.resolve('src/renderer/src')
// The naming lives in semantic.ts (cssVarName) and is the SAME function
// chrome.ts calls via semanticCssVars — so a regression there fails here.
// Single naming system: semanticVars() keys ARE the custom property names, so
// what chrome.ts writes is exactly what this test inspects.
const names = (vars) => new Set(Object.keys(vars).filter((k) => k !== 'color-scheme'))
// --- self-test: the converter must actually convert ------------------------
console.log('cssVarName:')
ok(cssVarName('textMuted') === '--ot-text-muted', 'textMuted -> --ot-text-muted')
ok(cssVarName('dangerFg') === '--ot-danger-fg', 'dangerFg -> --ot-danger-fg')
ok(cssVarName('text') === '--ot-text', 'text -> --ot-text (unchanged)')
ok(cssVarName('colorScheme') === 'color-scheme', 'colorScheme -> color-scheme')
// --- what JS emits, per branch ---------------------------------------------
const darkVars = semanticCssVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticCssVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkVars = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkNames = names(darkVars)
const lightNames = names(lightVars)
// --- self-test: the keys must BE the final CSS names ------------------------
// Custom properties are case-sensitive: a camelCase key would never satisfy
// var(--ot-text-muted), so a miss here is the silent-fallback regression.
console.log('keys are final CSS names (lowercase --ot-*):')
for (const [label, vars] of [['dark', darkVars], ['light', lightVars]]) {
const keys = Object.keys(vars).filter((k) => k !== 'color-scheme')
const bad = keys.filter((k) => !/^--ot-[a-z0-9-]+$/.test(k)).sort()
ok(bad.length === 0, `${label}: all ${keys.length} keys match /^--ot-[a-z0-9-]+$/${bad.length ? ` — bad: ${bad.join(', ')}` : ''}`)
ok(keys.length > 0, `${label}: emits names (guard is not vacuous)`)
}
ok(Object.keys(darkVars).some((k) => k === '--ot-text-muted'), 'multi-word name is kebab-case (--ot-text-muted)')
ok(Object.keys(darkVars).some((k) => k === '--ot-danger-fg'), 'compound name is kebab-case (--ot-danger-fg)')
console.log('branches emit the same key set:')
const onlyDark = [...darkNames].filter((n) => !lightNames.has(n)).sort()
const onlyLight = [...lightNames].filter((n) => !darkNames.has(n)).sort()
+11 -11
View File
@@ -23,14 +23,14 @@ ok(isLightTheme('#282a36') === false, '#282a36 is dark')
console.log('dark branch locks anchors (zero regression):')
const dark = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
ok(dark['textMuted'] === SEMANTIC.muted, 'textMuted = anchor')
ok(dark['success'] === SEMANTIC.success, 'success = anchor')
ok(dark['danger'] === SEMANTIC.danger, 'danger = anchor')
ok(dark['warning'] === SEMANTIC.warning, 'warning = anchor')
ok(dark['info'] === SEMANTIC.info, 'info = anchor')
ok(dark['scrollbar'] === SEMANTIC.scrollbar, 'scrollbar = anchor')
ok(dark['shadow'] === SEMANTIC.shadow, 'shadow = anchor')
ok(dark['colorScheme'] === 'dark', 'colorScheme dark')
ok(dark['--ot-text-muted'] === SEMANTIC.muted, '--ot-text-muted = anchor')
ok(dark['--ot-success'] === SEMANTIC.success, '--ot-success = anchor')
ok(dark['--ot-danger'] === SEMANTIC.danger, '--ot-danger = anchor')
ok(dark['--ot-warning'] === SEMANTIC.warning, '--ot-warning = anchor')
ok(dark['--ot-info'] === SEMANTIC.info, '--ot-info = anchor')
ok(dark['--ot-scrollbar'] === SEMANTIC.scrollbar, '--ot-scrollbar = anchor')
ok(dark['--ot-shadow'] === SEMANTIC.shadow, '--ot-shadow = anchor')
ok(dark['color-scheme'] === 'dark', 'color-scheme dark')
console.log('light branch meets ratios:')
for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
@@ -44,13 +44,13 @@ for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
const x = L(a), y = L(b)
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
for (const key of ['textMuted', 'textSecondary', 'successFg', 'dangerFg', 'warningFg', 'infoFg']) {
for (const key of ['--ot-text-muted', '--ot-text-secondary', '--ot-success-fg', '--ot-danger-fg', '--ot-warning-fg', '--ot-info-fg']) {
ok(cr(v[key], bg) >= 4.5, `${bg} ${key} ${v[key]} >= 4.5 (${cr(v[key], bg).toFixed(2)})`)
}
for (const key of ['success', 'danger', 'warning', 'info']) {
for (const key of ['--ot-success', '--ot-danger', '--ot-warning', '--ot-info']) {
ok(cr(v[key], bg) >= 3, `${bg} ${key} ${v[key]} >= 3.0 (${cr(v[key], bg).toFixed(2)})`)
}
ok(v['colorScheme'] === 'light', `${bg} colorScheme light`)
ok(v['color-scheme'] === 'light', `${bg} color-scheme light`)
}
if (failed > 0) {
+93
View File
@@ -20,6 +20,7 @@ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'no
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const require = createRequire(import.meta.url)
const userData = mkdtempSync(join(tmpdir(), 'ot-settings-'))
@@ -60,6 +61,33 @@ for (const action of ['tray', 'exit', 'ask']) {
ok(back === 'tray', `unknown closeAction falls back to 'tray' (got '${back}')`)
}
// ---- 1b. numeric font settings are clamped on load ---------------------------
// xterm's constructor THROWS on lineHeight < 1 and the throw unmounts the whole
// dockview pane. The value is persisted, so one bad entry bricked every start
// until the file was hand-edited. The sanitizer must repair it instead.
console.log('[font settings clamp]')
{
const term = store.loadSettings().terminal
for (const [key, bad, want] of [
['lineHeight', 0.85, 1],
['lineHeight', 0.95, 1],
['lineHeight', 9, 2],
['fontSize', 4, 8],
['fontSize', 99, 40],
['letterSpacing', -3, 0],
['letterSpacing', 50, 10]
]) {
const back = (await roundTrip({ terminal: { ...term, [key]: bad } })).terminal[key]
ok(back === want, `${key} ${bad} is clamped to ${want} on load (got ${back})`)
}
// A legal value must survive untouched — the clamp is not a reset.
const legal = (await roundTrip({ terminal: { ...term, lineHeight: 1.35, fontSize: 18, letterSpacing: 1.5 } })).terminal
ok(
legal.lineHeight === 1.35 && legal.fontSize === 18 && legal.letterSpacing === 1.5,
'legal font values are preserved verbatim'
)
}
// ---- 2. highlight-rule repair ----------------------------------------------
console.log('[highlight rules]')
{
@@ -177,6 +205,51 @@ console.log('[value bands]')
const dropped = (await roundTrip({ highlightRules: [rule({ basic: 'yes' })] })).highlightRules
ok(dropped.length === 1 && dropped[0].basic === undefined, 'a non-boolean basic flag is dropped')
}
{
// lineAnchor: a rule anchored per line must come back still anchored, and a
// rule that never opted in must stay absent (not `false`) so settings.json
// stays compact.
const kept = (await roundTrip({ highlightRules: [rule({ lineAnchor: true })] })).highlightRules
ok(kept.length === 1 && kept[0].lineAnchor === true, 'a line-anchored rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].lineAnchor === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ lineAnchor: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].lineAnchor === undefined,
'a non-boolean lineAnchor flag is dropped'
)
}
{
// notify (per-rule "alert on hit"): same shape as lineAnchor — `true` survives,
// absent stays absent, anything non-boolean is dropped rather than coerced.
const kept = (await roundTrip({ highlightRules: [rule({ notify: true })] })).highlightRules
ok(kept.length === 1 && kept[0].notify === true, 'a notify rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].notify === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ notify: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].notify === undefined,
'a non-boolean notify flag is dropped'
)
// An explicit `false` is indistinct from absent after a save: the sanitizer
// normalises it away so settings.json never grows a dead key.
const off = (await roundTrip({ highlightRules: [rule({ notify: false })] })).highlightRules
ok(off.length === 1 && off[0].notify === undefined, 'notify: false is normalised to undefined')
}
{
// The preset rule set ships `secret` with the alert on (a leaked key is what
// the feature exists for) and nothing else flagged. Read from the preset
// constant rather than from disk: the user's stored set may legitimately have
// the flag off (see the upgrade note in the report), so this asserts the ship
// default, not the loaded result.
const flagged = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.notify === true).map((r) => r.id)
ok(
flagged.length === 1 && flagged[0] === 'secret',
`only the secret preset notifies by default (got ${flagged.join(',')})`
)
const secret = DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === 'secret')
ok(secret?.basic === true && secret?.notify === true, 'the secret preset notifies in both highlight modes')
}
{
for (const on of [true, false]) {
const back = await roundTrip({
@@ -196,6 +269,26 @@ console.log('[value bands]')
)
}
}
{
// cjkFontFamily: the CJK fallback slot appended to the font stack. Empty is
// the default (append nothing, unchanged behaviour); a real family name and
// a non-string — the settings file is hand-editable, and a number here would
// otherwise reach `appendCjk` and be concatenated into the stack as text.
ok(store.loadSettings().terminal.cjkFontFamily === '', 'the CJK fallback defaults to empty')
for (const cjk of ['Microsoft YaHei', "'Noto Sans CJK SC'", '']) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: cjk }
})
ok(back.terminal.cjkFontFamily === cjk, `CJK fallback '${cjk}' survives the round trip`)
}
const dropped = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: 42 }
})
ok(
dropped.terminal.cjkFontFamily === '',
`a non-string CJK fallback is dropped back to empty (got '${dropped.terminal.cjkFontFamily}')`
)
}
{
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
await roundTrip({ highlightRules: rules })