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