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 已同步。
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
/**
|
||
* 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')
|