From cee616a23ac413249487ed23ea7698c15b347c22 Mon Sep 17 00:00:00 2001 From: Bill Date: Sat, 17 Oct 2026 20:23:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E4=BD=93=E4=B8=8E=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E7=B3=BB=E7=BB=9F=E6=94=B9=E8=BF=9B=EF=BC=88CJK=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E3=80=81ReDoS=20=E9=98=B2=E6=8A=A4=E3=80=81?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E8=A7=84=E5=88=99=E3=80=81=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E7=B1=BB=E8=AE=BE=E7=BD=AE=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。 ## 修复的真实 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 已同步。 --- AGENTS.md | 36 +- docs/PLAN-2026-10-17-font-highlight.md | 181 +++++++++ package-lock.json | 11 + package.json | 3 +- src/main/settingsStore.ts | 67 +++ src/renderer/src/main.tsx | 6 +- src/renderer/src/monitor/MonitorPanel.tsx | 18 +- src/renderer/src/settings/HighlightTab.tsx | 351 +++++++++++++++- src/renderer/src/settings/SettingsTabs.tsx | 174 +++++++- src/renderer/src/settings/store.ts | 9 + src/renderer/src/terminal/TerminalView.tsx | 229 +++++++++-- src/renderer/src/terminal/fontStack.ts | 109 +++++ src/renderer/src/terminal/highlightEngine.ts | 334 +++++++++++++-- src/renderer/src/terminal/highlightStats.ts | 77 +++- src/renderer/src/theme/chrome.ts | 13 +- src/renderer/src/theme/semantic.ts | 140 ++++--- src/shared/highlightIO.ts | 2 + src/shared/i18n/dicts/en/settings.ts | 27 +- src/shared/i18n/dicts/ja/settings.ts | 27 +- src/shared/i18n/dicts/zh-CN/settings.ts | 27 +- src/shared/i18n/dicts/zh-TW/settings.ts | 27 +- src/shared/linkRules.ts | 274 +++++++++++++ src/shared/settings.ts | 106 ++++- tests/font-stack.mjs | 152 +++++++ tests/hl-rules.mjs | 406 ++++++++++++++++++- tests/link-rules.mjs | 333 +++++++++++++++ tests/ot-var-names.mjs | 33 +- tests/semantic-theme.mjs | 22 +- tests/settings-store.mjs | 93 +++++ 29 files changed, 3071 insertions(+), 216 deletions(-) create mode 100644 docs/PLAN-2026-10-17-font-highlight.md create mode 100644 src/renderer/src/terminal/fontStack.ts create mode 100644 src/shared/linkRules.ts create mode 100644 tests/font-stack.mjs create mode 100644 tests/link-rules.mjs diff --git a/AGENTS.md b/AGENTS.md index d9fa79c..e9ae5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 是字面量键联合——**曾经是 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` 带 `(?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 diff --git a/package-lock.json b/package-lock.json index 2d6d848..c86daca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 563a94e..a86059a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index c14c4ea..c6feda4 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -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 = { + 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 @@ -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 }).terminal + if (stored === null || typeof stored !== 'object') return + const fatalKeys = [ + ...(Object.keys(FONT_LIMITS) as Array), + 'cursorStyle', + 'cursorInactiveStyle', + 'rendererMode' + ] as Array + 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 = Promise.resolve() /** diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 05d8801..a572f95 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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 /** diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index 5aaeaaa..91230e7 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -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 { - 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
{tFor(language, 'ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
- +
@@ -254,7 +254,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
- +
{memPct.toFixed(0)}%
@@ -303,8 +303,8 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
- - + +
diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx index 3b37506..049d3b0 100644 --- a/src/renderer/src/settings/HighlightTab.tsx +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -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} /> + + + ) +} + +/** + * 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(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): Promise => { + // 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 => { + 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 = [ + { + title: t('settings.highlight.linkEnabled'), + dataIndex: 'enabled', + width: 64, + align: 'center', + render: (value: boolean | undefined, record) => ( + void patchRule(record, { enabled: checked })} + /> + ) + }, + { + title: t('settings.highlight.linkPattern'), + dataIndex: 'pattern', + ellipsis: true, + render: (value: string, record) => { + const { ok, message } = tryCompile(value) + return ( +
+ + {value} + + {!ok && ( + + ⚠ + + )} + {record.note != null && record.note !== '' && ( + {record.note} + )} +
+ ) + } + }, + { + title: t('settings.highlight.linkTemplate'), + dataIndex: 'template', + ellipsis: true, + render: (value: string) => ( + + + {value} + + + ) + }, + { + title: t('settings.highlight.actions'), + key: 'action', + width: 120, + align: 'center', + render: (_value, record) => ( +
+ + + void setLinkRules( + (useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES).filter( + (rule) => rule.id !== record.id + ) + ) + } + > + + +
+ ) + } + ] + + return ( +
+
+ {t('settings.highlight.linksTitle')} + +
+ {t('settings.highlight.linksHint')} + + + 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 }} + > +
+
+ {t('settings.highlight.linkPattern')} +
+ 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 && ( +
{compile.message}
+ )} + {compile.ok && compile.slow && ( +
{t('settings.highlight.patternSlow')}
+ )} + {t('settings.highlight.linkPatternHint')} +
+
+
+ {t('settings.highlight.linkTemplate')} +
+ setTemplate(e.target.value)} + placeholder="https://example.com/item/$1" + maxLength={LINK_TEMPLATE_MAX} + className={templateOk ? undefined : 'hl-editor-input-bad'} + /> + {!templateOk && ( +
+ {t('settings.highlight.linkInvalidTemplate')} +
+ )} + {t('settings.highlight.linkTemplateHint')} +
+
+
+ {t('settings.highlight.note')} + setNote(e.target.value)} + placeholder={t('settings.highlight.notePlaceholder')} + maxLength={LINK_NOTE_MAX} + /> +
+
+ {t('settings.highlight.linkEnabled')} + +
+
+
) } @@ -379,6 +677,11 @@ function PatternCell({ rule }: { rule: HighlightRule }): React.JSX.Element { const { ok, message } = tryCompile(rule.pattern) return (
+ {rule.lineAnchor === true && ( + + {'^$'} + + )} {rule.caseInsensitive === true && ( Aa @@ -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 && (
{compile.message}
)} + {compile.ok && compile.slow && ( +
{t('settings.highlight.patternSlow')}
+ )}
{t('settings.highlight.caseInsensitive')} {t('settings.highlight.caseInsensitiveHint')}
+
+ patch({ lineAnchor: c })} + /> + {t('settings.highlight.lineAnchor')} + {t('settings.highlight.lineAnchorHint')} +
+
+ patch({ notify: c })} + /> + {t('settings.highlight.notify')} + {t('settings.highlight.notifyHint')} +
diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 553dc02..e836c18 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -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 ( + + + {name} + + {mono && ( + + {t('settings.font.monoBadge')} + + )} + + ) +} + +/** 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([]) @@ -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() - 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: {f} }) + const isMono = isMonospaceFont(f) + const item = { value: f, label: } + 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 (
{t('settings.font.default')} }, ...fontOptions ]} - filterOption={(input, option) => - typeof option?.value === 'string' && - option.value.toLowerCase().includes(input.toLowerCase()) - } + filterOption={filterFontOption} + /> + } + /> + void updateTerminal({ cjkFontFamily: v })} + options={cjkOptions} + filterOption={filterFontOption} /> } /> @@ -89,8 +209,8 @@ export function FontSettingsTab(): React.JSX.Element { control={ { if (v !== null) void updateTerminal({ fontSize: v }) @@ -132,8 +252,8 @@ export function FontSettingsTab(): React.JSX.Element { control={ { @@ -148,8 +268,8 @@ export function FontSettingsTab(): React.JSX.Element { control={ { @@ -160,7 +280,7 @@ export function FontSettingsTab(): React.JSX.Element { /> 1 bill users 42 Sep 5 09:41{' '} index.ts , + + -rw-r--r--{' '} + 1 bill users 1024 10月 17 09:30{' '} + 会议记录.md + , + + ✓ + 构建完成,耗时 3.2s + , + + 正在编译 + src/组件/终端视图.tsx + … + , bill@desktop:~$ _ @@ -662,7 +796,7 @@ function TerminalPreview({ ] const gridStyle: React.CSSProperties = { - fontFamily, + fontFamily: quoteFontStack(fontFamily), fontSize, fontWeight, lineHeight, diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts index db9106f..0cbf8c8 100644 --- a/src/renderer/src/settings/store.ts +++ b/src/renderer/src/settings/store.ts @@ -19,6 +19,8 @@ export interface SettingsState { setHighlightRules: (rules: AppSettings['highlightRules']) => Promise /** replace highlightProfiles array and persist */ setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise + /** replace linkRules array and persist */ + setLinkRules: (rules: NonNullable) => Promise /** shallow-merge into settings.system and persist */ updateSystem: (partial: Partial) => Promise /** 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((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 } } diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 59a0ff7..4976070 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -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(() => { 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() + 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( + () => 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()) + const notifyMatch = useCallback( + (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 | 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 diff --git a/src/renderer/src/terminal/fontStack.ts b/src/renderer/src/terminal/fontStack.ts new file mode 100644 index 0000000..182aff5 --- /dev/null +++ b/src/renderer/src/terminal/fontStack.ts @@ -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(', ') +} diff --git a/src/renderer/src/terminal/highlightEngine.ts b/src/renderer/src/terminal/highlightEngine.ts index 975c44b..5d798d4 100644 --- a/src/renderer/src/terminal/highlightEngine.ts +++ b/src/renderer/src/terminal/highlightEngine.ts @@ -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 +/** + * 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-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>]|\x1b[0-9:; 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) } } diff --git a/src/renderer/src/terminal/highlightStats.ts b/src/renderer/src/terminal/highlightStats.ts index 9714cd5..4eef27e 100644 --- a/src/renderer/src/terminal/highlightStats.ts +++ b/src/renderer/src/terminal/highlightStats.ts @@ -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 = new Map() +/** Opaque per-pane id → that pane's live sink map (kept by reference). */ +const panes = new Map>() +let merged: Map = 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 /** 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): 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() + 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): 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 { diff --git a/src/renderer/src/theme/chrome.ts b/src/renderer/src/theme/chrome.ts index 766f4e4..f5beddf 100644 --- a/src/renderer/src/theme/chrome.ts +++ b/src/renderer/src/theme/chrome.ts @@ -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) } diff --git a/src/renderer/src/theme/semantic.ts b/src/renderer/src/theme/semantic.ts index 849b427..412cf62 100644 --- a/src/renderer/src/theme/semantic.ts +++ b/src/renderer/src/theme/semantic.ts @@ -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 { +/** 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 + +/** Values for the --ot-* variables, keyed by their final CSS custom property + * names (chrome.ts writes them straight onto ). */ +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 { - const out: Record = {} - for (const [key, value] of Object.entries(semanticVars(colors, light))) out[cssVarName(key)] = value - return out -} diff --git a/src/shared/highlightIO.ts b/src/shared/highlightIO.ts index 7f9c5e5..0e6b84c 100644 --- a/src/shared/highlightIO.ts +++ b/src/shared/highlightIO.ts @@ -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() } : {}), diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index 3307da6..1883614 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -33,9 +33,14 @@ const settings: Record = { '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 = { '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 = { '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 = { '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', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 5731424..dbc9ec4 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -33,9 +33,14 @@ const settings: Record = { '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 = { '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 = { '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 = { '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': 'ハイライトルールがありません。「+ ルールを追加」から作成できます', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index d4497d4..4d643dd 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -33,9 +33,14 @@ const settings: Record = { '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 = { '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 = { '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 = { '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': '暂无高亮规则,点击「+ 新增规则」创建', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index 9d7e1ee..b31b510 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -33,9 +33,14 @@ const settings: Record = { '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 = { '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 = { '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 = { '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': '尚無高亮規則,點擊「+ 新增規則」建立', diff --git a/src/shared/linkRules.ts b/src/shared/linkRules.ts new file mode 100644 index 0000000..b6a3f28 --- /dev/null +++ b/src/shared/linkRules.ts @@ -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() + raw.forEach((entry, index) => { + if (entry === null || typeof entry !== 'object') { + warn?.(`linkRules[${index}]: not an object — skipped`) + return + } + const candidate = entry as Record + 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) +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 1b0140d..08f67f0 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -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 `/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: { diff --git a/tests/font-stack.mjs b/tests/font-stack.mjs new file mode 100644 index 0000000..e315892 --- /dev/null +++ b/tests/font-stack.mjs @@ -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) diff --git a/tests/hl-rules.mjs b/tests/hl-rules.mjs index 0bc98f5..39cd7ca 100644 --- a/tests/hl-rules.mjs +++ b/tests/hl-rules.mjs @@ -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) \ No newline at end of file diff --git a/tests/link-rules.mjs b/tests/link-rules.mjs new file mode 100644 index 0000000..25c9408 --- /dev/null +++ b/tests/link-rules.mjs @@ -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') diff --git a/tests/ot-var-names.mjs b/tests/ot-var-names.mjs index f6313b4..696873a 100644 --- a/tests/ot-var-names.mjs +++ b/tests/ot-var-names.mjs @@ -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() diff --git a/tests/semantic-theme.mjs b/tests/semantic-theme.mjs index 1ae29bb..c1cd82d 100644 --- a/tests/semantic-theme.mjs +++ b/tests/semantic-theme.mjs @@ -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) { diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs index 1dbdff2..e7e220b 100644 --- a/tests/settings-store.mjs +++ b/tests/settings-store.mjs @@ -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 })