CI / typecheck + test + build (windows) (push) Waiting to run
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。
## 修复的真实 bug
**设置越界导致窗格永久死亡(用户报告)**
行高设 0.95 → xterm 构造/热更抛错 → 错误边界卸载整个 dockview 窗格;坏值已
落盘,每次重启都崩。根因是一整类缺陷:UI 允许值 / 配置能写值 与 xterm 硬约束
不一致,且主进程不校验。同类还有 scrollback<0(抛错)、cursorStyle 非法值
(更隐蔽:构造期被吞掉、只有热更期抛错,导致「重试」按钮永远无效、窗格死循环)。
修复:FONT_LIMITS/CURSOR_STYLES/RENDERER_MODES 收拢到 shared 层单一来源,
主进程加载时钳制 + 白名单化,healClampedFontSettings() 回写自愈;设置页
InputNumber、字体热键共用同一常量(照 LOCK_AUTO_DELAYS 先例,杜绝再次漂移)。
**perm 预设锚定失效**
^[-dlbcps]... 的 ^ 锚定的是「转义序列之间的 text run 起点」而非行首,ls -l
多行输出只有第一行权限位上色;带颜色的 ls(run 以转义切开)完全不触发。
改为 (?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])——用负向前瞻而不是
(?=\s|$) 是因为后者会让带 ACL `+` / macOS `@` 后缀的真实 ls -l 输出不再染色。
旧 pattern 登记进 LEGACY_BUILTIN_PATTERNS,存量安装下次启动自动升级。
**高亮 span 吃掉应用自身 SGR**
wrap 固定追加 \x1b[0m,把应用设的颜色一并清掉。改为 SgrState 跟踪并重放。
四条护栏(每条都踩过坑,详见 AGENTS.md):
- reset 判定解析参数:ESC[00m/ESC[0;0m/ESC[0;32m(Go/Java 日志库常写)原先
被追加进累积器,逐行增长直到撞上限,之后所有 span 恢复永久失效
- 回放闸门按序列条数而非字节数:同一个"单个前景色"长度差近 4 倍,按字节卡
会砍掉真彩色却放过索引色
- 恢复串只插在可见间隙和最后一个 span 后:每 span 都重放会让 Kimi 这类全彩
TUI(~19 span/行)把 9.5KB 输出膨胀到 40.8KB(4.30x),渲染线程实测卡
18.4 秒。现为 3.05x 且长间隙着色正常
- 基准测试守门(膨胀 < 3.2x),改参数必须重跑
**CSI 私有参数导致整行失去高亮**
ESCAPE_RE 漏了 <>= 前缀与中间字节,\x1b[>4;1m(modifyOtherKeys)、
\x1b[<0;1;2M(SGR 鼠标)、\x1b[=1;1$y(DECRQM) 让整行成含裸 ESC 的文本,该行
之后所有关键词失去高亮(vim/tmux 常发)。
**超长行拖垮整段高亮**:一条 >4KB 的行让整个 text run 失去高亮。改为逐行降级。
**统计「最后写入者赢」**:模块级 totals 被各 pane 覆盖,设置页数据在窗格间
跳动。改为按 pane 实例 UUID 聚合(不能用 sessionId——SSH 分屏同一 session 挂
两个 pane,正好复制该 bug)。
## 新增能力
- **CJK 对齐三件套**:@xterm/addon-unicode11@0.10.0-beta.301(peer 恰好接受
锁定的 6.1.0-beta.304,升 xterm 必须同步重配)、rescaleOverlappingGlyphs、
cjkFontFamily 回退槽位。默认字体栈没有任何 CJK 字体,中文靠 Chromium 回退
到比例字体导致列错位
- **字体名引号包裹**:quoteFontStack() 逐段判定(含反斜杠转义——只转义 " 会
让尾部反斜杠把闭合引号转义掉,CSS 字符串不终止)
- **等宽徽标**:canvas measureText 逐字符采样,等宽排前
- **规则按行匹配**(lineAnchor → m flag)
- **命中提醒**(notify + onMatch 事件出口,60s 冷却,只显示规则名不显示原文)
- **可点击链接规则**(WezTerm hyperlink_rules 式,安全闸门在生成 span 时就
丢弃非 http/https 目标)
## 其它
- 主题语义变量改为单一命名体系:semanticVars() 键就是最终 CSS 属性名,字面量
键联合让拼错成为编译错误;删除 cssVarName()/semanticCssVars() 中间层
(自定义属性名大小写敏感,转换被绕过会静默回落到深色兜底值)
- 删除死代码:automaticFontFallback 与顶层 overviewRuler 在 6.1.0-beta.304
中均不存在,且移除了让任何伪造键都能通过 tsc 的 as 断言
- ReDoS 防护(MAX_PATTERN_LEN + 编辑器 backtracksBadly 实时探测)——注意这
只是止血,(a+)+$ 配 30 字符实测仍卡 8.2 秒,根治需把正则执行挪进 Worker
测试 29 → 32(新增 font-stack、link-rules),全部通过;AGENTS.md 已同步。
94 lines
4.8 KiB
JavaScript
94 lines
4.8 KiB
JavaScript
/**
|
|
* Fail-closed guard for the --ot-* semantic layer's NAMES (not values).
|
|
*
|
|
* CSS custom properties are case-sensitive, so `setProperty('--ot-dangerFg')`
|
|
* does NOT satisfy `var(--ot-danger-fg)`: the reference just falls back to the
|
|
* dark :root literal and the light theme silently loses its fix. Value tests
|
|
* (semantic-theme / theme-contrast) cannot see that, so this test locks the
|
|
* three sets together:
|
|
* 1) every var(--ot-*) referenced in renderer source is actually emitted;
|
|
* 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).
|
|
* 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 { semanticVars } from '../src/renderer/src/theme/semantic.ts'
|
|
|
|
let failed = 0
|
|
const ok = (cond, msg) => {
|
|
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
|
if (!cond) failed += 1
|
|
}
|
|
|
|
const ROOT = path.resolve('src/renderer/src')
|
|
// 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'))
|
|
|
|
// --- what JS emits, per branch ---------------------------------------------
|
|
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()
|
|
ok(onlyDark.length === 0, `no dark-only keys${onlyDark.length ? ` (${onlyDark.join(', ')})` : ''}`)
|
|
ok(onlyLight.length === 0, `no light-only keys${onlyLight.length ? ` (${onlyLight.join(', ')})` : ''}`)
|
|
ok(darkVars['color-scheme'] === 'dark' && lightVars['color-scheme'] === 'light', 'color-scheme switches per branch')
|
|
|
|
// --- what the CSS references ------------------------------------------------
|
|
function walk(dir, out = []) {
|
|
for (const f of fs.readdirSync(dir)) {
|
|
const p = path.join(dir, f)
|
|
if (fs.statSync(p).isDirectory()) walk(p, out)
|
|
else if (/\.(css|tsx|ts)$/.test(f)) out.push(p)
|
|
}
|
|
return out
|
|
}
|
|
const referenced = new Set()
|
|
for (const file of walk(ROOT)) {
|
|
for (const m of fs.readFileSync(file, 'utf8').matchAll(/var\((--ot-[A-Za-z0-9-]+)/g)) referenced.add(m[1])
|
|
}
|
|
|
|
console.log('every referenced var is emitted:')
|
|
const dangling = [...referenced].filter((n) => !darkNames.has(n)).sort()
|
|
ok(dangling.length === 0, dangling.length ? `dangling: ${dangling.join(', ')}` : `${referenced.size} reference(s) all emitted`)
|
|
|
|
// --- the static :root block in global.css ----------------------------------
|
|
const css = fs.readFileSync(path.join(ROOT, 'global.css'), 'utf8')
|
|
const rootBlock = /:root\s*\{([\s\S]*?)\}/.exec(css)
|
|
const declared = new Set()
|
|
if (rootBlock) for (const m of rootBlock[1].matchAll(/(--ot-[A-Za-z0-9-]+)\s*:/g)) declared.add(m[1])
|
|
|
|
console.log(':root fallback block matches the emitted set (no dark-only leftovers):')
|
|
const undeclared = [...darkNames].filter((n) => !declared.has(n)).sort()
|
|
const extra = [...declared].filter((n) => !darkNames.has(n)).sort()
|
|
ok(rootBlock !== null, ':root block found in global.css')
|
|
ok(undeclared.length === 0, undeclared.length ? `emitted but not declared: ${undeclared.join(', ')}` : 'all emitted names declared')
|
|
ok(extra.length === 0, extra.length ? `declared but never emitted: ${extra.join(', ')}` : 'no extra declared names')
|
|
|
|
if (failed > 0) {
|
|
console.error(`ot-var-names: ${failed} check(s) failed`)
|
|
process.exit(1)
|
|
}
|
|
console.log('ot-var-names: all ok')
|