CI / typecheck + test + build (windows) (push) Canceled after 0s
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。
## 修复的真实 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 已同步。
275 lines
9.7 KiB
TypeScript
275 lines
9.7 KiB
TypeScript
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)
|
||
}
|