feat(highlight): preset overhaul, categories, stats, theme colours, per-host profiles

Rules & engine:
- 22 presets (was 11): split status into okstate/warnstate/badstate, add delop,
  createop, danger, secret, level, exitcode, percent, http; status words are
  case-insensitive and cover the ✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠ symbol set
- value bands: the first number in a match picks the colour
  (percent: <20% red / 20-50% yellow / 50-80% light green / >=80% green)
- optional per-rule `caseInsensitive`, `category`, `bands`; load-time
  `refreshBuiltinRules` upgrades untouched built-in patterns in place

Settings page:
- three-way master switch `highlightMode` (all / basic / off); `basic` runs only
  the five safety+status rules and `off` empties the rule set rather than
  bypassing HighlightStream (which would drop the held tail)
- category column + grouping (`highlightGroupByCategory`), per-rule hit/duration
  stats (`highlightStats`, opt-in sink, snapshot once a second), theme-following
  colours (`highlightThemeColors`, hue-bucket mapping onto the ANSI palette),
  import/export JSON envelope, live preview through the real engine
- named rule subsets bound per host (`highlightPerHost` + `highlightProfiles`
  + `SshConnection.highlightProfileId`); empty ruleIds = every rule

Tests: new tests/hl-rules.mjs (word boundaries, case flag, negative words,
bands, import/export, preview, basic mode, categories, stats, theme colours,
profiles) + profile round-trip in tests/settings-store.mjs
This commit is contained in:
Bill committed 2026-09-23 11:14:31 +08:00
1 parent 41b1cb1b25
commit e08e1cfec4
39 files changed
+3164 -89

No files matched your search

+1
View File
@@ -27,6 +27,7 @@ tests/.commands-store.cjs
tests/.settings-store.cjs
tests/.session-e2e.cjs
tests/.hl-split-smoke.cjs
tests/.hl-rules.cjs
tests/.zmodem-e2e.cjs
release/
+23
View File
@@ -51,3 +51,26 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
## 关键词高亮
- 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过**
- 状态类预设(danger/okstate/warnstate/badstate)priority 排在 `shellkw` **之前**:`done` 既是 shell 关键字又是成功词、`if` 还在 `dd if=` 里,先跑谁就由谁着色
- 状态符号(`✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠`)**不能放进 `\b…\b` 组**:`\b✓` 永不成立。预设把它们写在 `\b(?:…)\b|[✓✔✅]\uFE0F?` 的第二个分支里,尾随的 `\uFE0F?` 是为了把 emoji 变体选择符一起圈进着色范围
- 严重级别按颜色拆开:成功(okstate 绿)/ 警告(warnstate 黄)/ 错误·致命(badstate 红,含 `CRITICAL`、`FATAL`、`PANIC`)/ 删除·移动·覆盖(delop 橙)/ 新建·创建·安装(createop 亮绿);`badstate` 里的 `NOT …` 分支负责 `not ok` / `not found`,`okstate` 的反向断言保证它不会被染绿
- delop/createop 只列**操作动词**(delete/remove/rm/mkdir/touch/add/install/clone/…),`export` 等 shell 关键字仍归 `shellkw`,别把两边都写进去抢 span
- **数值分级(`bands`)**:匹配里的第一个数字决定颜色(最后一个 `min <= 值` 的分级胜出),无数字或低于最小分级时回落到 `color.fg`;预设 `percent` 用它做百分比(<20% 红 / 20–50% 黄 / 50–80% 浅绿 / ≥80% 绿)。它的 priority 22 **必须早于 `numbers`(25)**,否则 `45%` 会先被数字规则整段吃掉;`numbers` 规则本身不含百分比分支
- 词干要自带词尾(`DELET(?:E|ED|ES|ING|ION)` 而非 `DELETE(?:D|S|ING)?`,否则漏 `deleting`);不成词的词干(MOV/SAV/CLON/PURG/ERAS/WIP/REVOK)必须强制要求词尾
- 上下文敏感的规则用 **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 被别的规则抢走时能一眼看出来
- 分类(`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 格式化)
- 性能护栏(别拆):`MAX_CHUNK` 512KB 整块跳过、`MAX_LINE_LEN` 4KB 超长行不跑规则(挡 `(a+)+b` 这类回溯)、`MAX_PER_RULE` 300 每条规则每块上限。实测 180–200KB 混合输出:**全预设 4–9ms/块**(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、**basic 档 ~1.3ms/块(≈49µs/chunk)**、**off 档 0**;单条规则最贵的是 `http`(0.82ms)、`percent`、`danger`
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(danger/secret/okstate/warnstate/badstate 这 5 条),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
- 按主机绑定规则集(`settings.terminal.highlightPerHost`,默认关):`HighlightProfile { id, name, ruleIds }` 存在 `AppSettings.highlightProfiles`(顶层数组,仿 `customThemes`),清洗在 `src/shared/highlightProfiles.ts`;`ruleIds` **为空 = 全部规则**,`rulesForProfile` 对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,`excludedByProfile` 给设置页算「这个 profile 排除了哪些规则」。绑定存在 **`connections.json`**(`SshConnection.highlightProfileId`)而不是 settings,`TerminalView` 只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:`PUBLIC_KEYS`(update 路径靠它回写)、`toPublic()`、`saveConnection()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失
- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
- `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`
+1 -1
View File
@@ -26,7 +26,7 @@ npm test # 重建测试 bundle 后依次跑离线测试(详见「测
- 剪贴板(Ctrl+Shift+C/V、可选选中即复制)
- 粘贴风险确认(多行/长文本需手动确认,可关闭)
- 字体/字号/字重/字距/行高/回滚行数/光标样式实时生效
- 关键词高亮:11 条内置规则(权限、路径、错误状态、IP、URL 等),支持自定义正则规则
- 关键词高亮:22 条内置规则(权限、密钥泄露提醒、路径、危险命令、删除/新建操作、成功/警告/错误状态、日志级别、root 提示符、退出码、HTTP 状态码、耗时、百分比进度、IP、URL 等),状态类词不分大小写并覆盖 ✓/✗ 等状态符号,百分比 / 退出码 / HTTP 状态码 / 耗时按数值分级取色;规则支持自定义正则、「忽略大小写」与「数值分级」开关,可导入 / 导出 JSON 分享,编辑器内带测试文本实时预览,高亮模式三档(全部 / 仅基础 / 关闭,基础档只跑错误/成功/警告/危险命令/密钥等关键规则),规则可按分类(安全/状态/文件/网络/文本/数值)分组查看,可开启「命中/耗时」统计(默认关,关闭时零开销),可让高亮颜色跟随终端主题调色板,可新建规则集并按主机绑定(默认关,未绑定的主机跑全部规则)
**SSH 远程会话**
- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证
+1 -1
View File
@@ -68,7 +68,7 @@
- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴)
- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成)
- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 11 条预设 + 设置页管理,见 M2.5)
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 22 条预设 + 设置页管理,见 M2.5;状态类预设不分大小写、覆盖 ✓/✗ 符号,删除/新建操作单独着色,百分比/退出码/HTTP 状态码/耗时按数值分级取色,含密钥泄露提醒与危险管道,旧安装的预设规则在加载时按「未被用户改动过」升级)
- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅
## M6 — 多终端协同 ✅
+5 -4
View File
@@ -59,17 +59,18 @@
```bash
npm run typecheck # tsconfig.node.json + tsconfig.web.json
npm run build
npm test # = node tests/build-bundles.cjs && 下面 7 个测试(依次,全部离线可跑)
npm test # = node tests/build-bundles.cjs && 下面 8 个测试(依次,全部离线可跑)
node tests/ssh-loopback.mjs
node tests/commands-store.mjs
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/告警日志)
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
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/build-bundles.cjs` 单独重建全部 6 个 esbuild bundle(别名只存在于该脚本):
# `node tests/build-bundles.cjs` 单独重建全部 7 个 esbuild bundle(别名只存在于该脚本):
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .settings-store.cjs
# .zmodem-e2e.cjs .hl-split-smoke.cjs —— 少 --alias:@shared=./src/shared 会编译失败
# .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
# 打包:npm run dist(产物 release/)
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.mjs && node tests/.hl-split-smoke.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/settings-store.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",
"dist": "electron-vite build && electron-builder --win msi nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
},
+5 -1
View File
@@ -34,6 +34,7 @@ interface StoredConnection {
askPassphraseAtConnect: boolean
keyPath?: string
keepaliveIntervalSec: number
highlightProfileId?: string
createdAt: number
lastConnectedAt?: number
password_enc?: string
@@ -52,7 +53,8 @@ const PUBLIC_KEYS = [
'askPasswordAtConnect',
'askPassphraseAtConnect',
'keyPath',
'keepaliveIntervalSec'
'keepaliveIntervalSec',
'highlightProfileId'
] as const satisfies readonly (keyof Omit<StoredConnection, 'password_enc' | 'keyContent_enc' | 'passphrase_enc'>)[]
function encrypt(plain: string): string {
@@ -96,6 +98,7 @@ function toPublic(stored: StoredConnection): SshConnection {
askPassphraseAtConnect: stored.askPassphraseAtConnect,
keyPath: stored.keyPath,
keepaliveIntervalSec: stored.keepaliveIntervalSec,
highlightProfileId: stored.highlightProfileId,
createdAt: stored.createdAt,
lastConnectedAt: stored.lastConnectedAt,
savedAuth: {
@@ -175,6 +178,7 @@ export class ConnectionsStore {
askPassphraseAtConnect: input.askPassphraseAtConnect,
keyPath: input.keyPath,
keepaliveIntervalSec: input.keepaliveIntervalSec,
highlightProfileId: input.highlightProfileId,
createdAt: Date.now()
}
list.push(stored)
+126 -3
View File
@@ -5,6 +5,7 @@ import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
isHighlightCategory,
type AppSettings,
type HighlightRule,
type SystemSettings,
@@ -12,6 +13,7 @@ import {
} from '../shared/settings'
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { sanitizeProfiles } from '../shared/highlightProfiles'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
@@ -80,6 +82,39 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
warnings.push(`highlightRules[${index}].color repaired → ${DEFAULT_RULE_COLOR}`)
}
if (rule.caseInsensitive !== undefined && typeof rule.caseInsensitive !== 'boolean') {
warnings.push(`highlightRules[${index}].caseInsensitive ignored (not a boolean)`)
}
if (rule.basic !== undefined && typeof rule.basic !== 'boolean') {
warnings.push(`highlightRules[${index}].basic ignored (not a boolean)`)
}
if (rule.category !== undefined && !isHighlightCategory(rule.category)) {
warnings.push(`highlightRules[${index}].category ignored (unknown value)`)
}
// Value bands are repaired one entry at a time: a band without a usable min or
// colour is dropped, and what survives is sorted so the engine can walk it.
let bands: { min: number; fg: string }[] | undefined
if (rule.bands !== undefined) {
if (Array.isArray(rule.bands)) {
const kept: { min: number; fg: string }[] = []
rule.bands.forEach((entry, bandIndex) => {
const band = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>) : null
const min = typeof band?.min === 'number' ? band.min : Number(band?.min)
const fg = typeof band?.fg === 'string' ? band.fg.trim() : ''
if (band === null || !Number.isFinite(min) || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) {
warnings.push(`highlightRules[${index}].bands[${bandIndex}]: unusable — skipped`)
return
}
kept.push({ min, fg })
})
kept.sort((a, b) => a.min - b.min)
if (kept.length > 0) bands = kept
} else {
warnings.push(`highlightRules[${index}].bands ignored (not an array)`)
}
}
return {
...rule,
id: rule.id,
@@ -87,10 +122,82 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
enabled,
priority,
color,
bands,
// `undefined` rather than `false` so the flag stays absent unless it is on.
caseInsensitive: rule.caseInsensitive === true ? true : undefined,
basic: rule.basic === true ? true : undefined,
category: isHighlightCategory(rule.category) ? rule.category : undefined,
note: typeof rule.note === 'string' ? rule.note : undefined
} as HighlightRule
}
/**
* Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule
* whose pattern is still one of these was never edited by the user, so the
* load-time refresh below may swap in the current preset for it.
*/
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
}
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
const LEGACY_BUILTIN_IDS = [
'perm',
'path',
'shellkw',
'okstate',
'badstate',
'quoted',
'envvar',
'ipv4',
'datetime',
'numbers',
'url'
]
/**
* Carry an older install's preset rules onto the current ones.
*
* An upgrade must not throw the user's own work away, so both steps are guarded:
* - a built-in rule still holding a shipped pattern is refreshed in place; the
* pattern and case flag come from the preset, while id, colour, priority,
* note and the enabled switch stay exactly as the user left them;
* - preset rules that did not exist yet are appended only while the stored set
* is still exactly the older preset set (nothing deleted, nothing added) —
* the moment the list is the user's own, a rule they removed stays removed.
* Both steps are idempotent: a refreshed pattern no longer matches a legacy one,
* and an upgraded set never matches the older preset id list again.
*/
function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): HighlightRule[] {
const presets = new Map(DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, rule]))
let changed = false
const refreshed = rules.map((rule) => {
const legacy = LEGACY_BUILTIN_PATTERNS[rule.id]
const preset = presets.get(rule.id)
if (!legacy || preset === undefined || rule.builtin !== true || !legacy.includes(rule.pattern)) {
return rule
}
changed = true
warnings.push(`highlightRules.${rule.id}: preset pattern updated`)
return { ...rule, pattern: preset.pattern, caseInsensitive: preset.caseInsensitive }
})
// Presets added since are appended only while the stored set is *exactly* the
// older preset set. Anything else — a rule deleted, one added by hand — means
// the set is the user's, and a preset they threw away must not come back on
// every load.
const present = refreshed.map((rule) => rule.id)
const pristine =
present.length === LEGACY_BUILTIN_IDS.length &&
LEGACY_BUILTIN_IDS.every((id) => present.includes(id))
if (!pristine) return changed ? refreshed : rules
const missing = DEFAULT_HIGHLIGHT_RULES.filter((rule) => !present.includes(rule.id))
if (missing.length === 0) return changed ? refreshed : rules
for (const rule of missing) warnings.push(`highlightRules.${rule.id}: new built-in rule added`)
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
}
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
// An explicit empty array is a valid choice ("no highlighting"); only
// malformed data falls back to the built-in rules. Returning the defaults for
@@ -101,7 +208,7 @@ function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
const rule = coerceRule(entry, index, warnings)
if (rule) rules.push(rule)
})
return rules
return refreshBuiltinRules(rules, warnings)
}
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
@@ -165,9 +272,15 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
let highlightProfiles: unknown = []
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
const packageSettings = raw as {
terminal?: unknown
customThemes?: unknown
highlightRules?: unknown
highlightProfiles?: unknown
}
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
const merged: Record<string, unknown> = { ...terminal }
@@ -187,6 +300,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
if (packageSettings.highlightRules !== undefined) {
highlightRules = packageSettings.highlightRules
}
if (packageSettings.highlightProfiles !== undefined) {
highlightProfiles = packageSettings.highlightProfiles
}
}
let system: unknown = { ...DEFAULT_SYSTEM }
@@ -217,12 +333,18 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
}
const themes = sanitizeThemes(customThemes, errors)
const rules = sanitizeRules(highlightRules, errors)
return {
settings: {
terminal,
customThemes: themes,
highlightRules: sanitizeRules(highlightRules, errors),
highlightRules: rules,
highlightProfiles: sanitizeProfiles(
highlightProfiles,
new Set(rules.map((rule) => rule.id)),
(message) => errors.push(message)
),
system: system as SystemSettings
},
errors
@@ -296,6 +418,7 @@ export function loadSettings(): AppSettings {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM }
}
}
+6 -1
View File
@@ -57,6 +57,10 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
? rawGroup.trim()
: undefined
const rawProfile = values.highlightProfileId
const highlightProfileId =
typeof rawProfile === 'string' && rawProfile.trim() !== '' ? rawProfile.trim() : undefined
const input: SshConnectionInput = {
id: ctx.conn?.id,
name: String(values.name ?? '').trim(),
@@ -67,7 +71,8 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
askPasswordAtConnect,
askPassphraseAtConnect,
keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30,
group
group,
highlightProfileId
}
const secret = (key: SecretKind): string | undefined => {
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections'
import { getLanguage, t } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { AuthFieldsRenderer } from './Fields'
import { buildInput, num, hasSavedKind, type SecretKind } from './Common'
@@ -55,6 +56,9 @@ export function ConnectionEditDialog({
})
}, [open])
// Named highlight-rule subsets defined in Settings, for the profile Select.
const highlightProfiles = useSettingsStore((s) => s.settings.highlightProfiles)
// Populate / clear the form for each open + target change.
useEffect(() => {
if (!open) return
@@ -76,7 +80,8 @@ export function ConnectionEditDialog({
askPasswordAtConnect: editing?.askPasswordAtConnect,
askPassphraseAtConnect: editing?.askPassphraseAtConnect,
keyPath: editing?.keyPath,
keepaliveIntervalSec: editing?.keepaliveIntervalSec
keepaliveIntervalSec: editing?.keepaliveIntervalSec,
highlightProfileId: editing?.highlightProfileId
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editKey, form])
@@ -214,6 +219,14 @@ export function ConnectionEditDialog({
>
<InputNumber className="connections-keepalive-input" min={0} placeholder="30" />
</Form.Item>
<Form.Item name="highlightProfileId" label={t('ssh.dialog.highlightProfile')}>
<Select
allowClear
placeholder={t('ssh.dialog.highlightProfileDefault')}
options={highlightProfiles.map((p) => ({ value: p.id, label: p.name }))}
/>
</Form.Item>
</Form>
</Modal>
)
+1
View File
@@ -84,6 +84,7 @@ if (typeof window !== 'undefined' && !window.api) {
askPassphraseAtConnect: input.askPassphraseAtConnect,
keyPath: input.keyPath,
keepaliveIntervalSec: input.keepaliveIntervalSec,
highlightProfileId: input.highlightProfileId,
createdAt: Date.now(),
savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false }
}),
@@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
import { useSettingsStore } from './store'
interface ImportResult {
error?: ImportError
warnings: string[]
/** rules that made it in; 0 means nothing was imported */
count: number
}
/**
* Import / export of the rule set as JSON. The renderer can do all of this on
* its own (clipboard, FileReader, a download link), so no main-process dialog or
* IPC is involved — which also keeps the feature testable outside Electron.
*/
export function HighlightImportExport({
open,
onClose
}: {
open: boolean
onClose: () => void
}): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const [draft, setDraft] = useState('')
const [mode, setMode] = useState<'replace' | 'append'>('append')
const [copied, setCopied] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
const exported = useMemo(() => exportHighlightRules(highlightRules), [highlightRules])
// start clean each time the dialog opens
useEffect(() => {
if (!open) return
setDraft('')
setResult(null)
setCopied(false)
}, [open])
const handleCopy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(exported)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard can be blocked; the textarea stays selectable by hand
}
}
const handleDownload = (): void => {
const url = URL.createObjectURL(new Blob([exported], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = 'openterminal-highlight-rules.json'
link.click()
URL.revokeObjectURL(url)
}
const handleFile = (file: File): void => {
const reader = new FileReader()
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
reader.readAsText(file)
}
const errorText = (error: ImportError): string => {
if (error === 'not-json') return t('settings.highlight.importBadJson')
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
return t('settings.highlight.importEmpty')
}
const handleImport = async (): Promise<void> => {
const parsed = parseHighlightRules(draft)
if (parsed.error !== undefined) {
setResult({ error: parsed.error, warnings: [], count: 0 })
return
}
if (parsed.rules.length === 0) {
setResult({ warnings: parsed.warnings, count: 0 })
return
}
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
}
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnHidden
title={t('settings.highlight.importExportTitle')}
>
<div className="hl-io">
<div className="hl-io-section">
<div className="hl-io-label">{t('settings.highlight.exportSection')}</div>
<Input.TextArea
value={exported}
readOnly
autoSize={{ minRows: 3, maxRows: 6 }}
className="hl-io-json"
/>
<Space>
<Button size="small" onClick={() => void handleCopy()}>
{copied ? t('settings.highlight.copied') : t('settings.highlight.copy')}
</Button>
<Button size="small" onClick={handleDownload}>
{t('settings.highlight.download')}
</Button>
</Space>
</div>
<div className="hl-io-section">
<div className="hl-io-label">{t('settings.highlight.importSection')}</div>
<Input.TextArea
value={draft}
onChange={(e) => setDraft(e.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
className="hl-io-json"
/>
<Space wrap>
<label className="hl-io-file">
<input
type="file"
accept=".json,application/json"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}}
/>
<Button size="small">{t('settings.highlight.chooseFile')}</Button>
</label>
<span className="hl-editor-sub-label">{t('settings.highlight.importMode')}</span>
<Radio.Group
size="small"
value={mode}
onChange={(e) => setMode(e.target.value as 'replace' | 'append')}
>
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
</Radio.Group>
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
</Space>
{result && (
<Alert
type={result.error !== undefined || result.count === 0 ? 'error' : 'success'}
showIcon
message={
result.error !== undefined
? errorText(result.error)
: result.count === 0
? t('settings.highlight.importEmpty')
: t('settings.highlight.imported', { n: result.count })
}
description={
result.warnings.length > 0
? t('settings.highlight.importSkipped', {
n: result.warnings.length,
list: result.warnings.join('; ')
})
: undefined
}
/>
)}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import type { HighlightProfile } from '@shared/settings'
import { ruleLabel } from './ruleLabel'
import { useSettingsStore } from './store'
/**
* Named subsets of the highlight rules ("profiles"), plus the switch that makes
* them apply. A connection bound to a profile runs only that subset, which is how
* a production host gets a quieter set than a local shell. Nothing here affects a
* session until `highlightPerHost` is on and the connection is bound.
*/
export function HighlightProfiles(): React.JSX.Element {
const profiles = useSettingsStore((s) => s.settings.highlightProfiles)
const rules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightProfiles = useSettingsStore((s) => s.setHighlightProfiles)
const perHost = useSettingsStore((s) => s.settings.terminal.highlightPerHost)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
/** null = dialog closed; '' = creating; otherwise the profile being edited. */
const [editingId, setEditingId] = useState<string | null>(null)
const [name, setName] = useState('')
const [ruleIds, setRuleIds] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const editing = profiles.find((profile) => profile.id === editingId)
// seed the dialog each time it opens
useEffect(() => {
if (editingId === null) return
setName(editing?.name ?? '')
setRuleIds(editing?.ruleIds ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingId])
const handleSave = async (): Promise<void> => {
if (name.trim() === '') return
setSaving(true)
try {
const next = editing
? profiles.map((profile) =>
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
)
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
await setHighlightProfiles(next)
setEditingId(null)
} finally {
setSaving(false)
}
}
const columns: ColumnsType<HighlightProfile> = [
{
title: t('settings.highlight.profileName'),
dataIndex: 'name',
ellipsis: true
},
{
title: t('settings.highlight.profileRules'),
dataIndex: 'ruleIds',
width: 120,
align: 'center',
render: (ids: string[]) => (
<span className="hl-cell-note">
{ids.length === 0 ? t('settings.highlight.modeAll') : ids.length}
</span>
)
},
{
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.profileDeleteTitle')}
description={t('settings.deleteConfirmDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
>
<Button size="small" danger>
{t('common.delete')}
</Button>
</Popconfirm>
</div>
)
}
]
return (
<div className="hl-profiles">
<div className="hl-profiles-head">
<Tooltip title={t('settings.highlight.perHostHint')}>
<span className="hl-master">
<Switch
size="small"
checked={perHost}
onChange={(checked) => void updateTerminal({ highlightPerHost: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.perHost')}</span>
</span>
</Tooltip>
<Button size="small" onClick={() => setEditingId('')}>
{t('settings.highlight.profileNew')}
</Button>
</div>
<span className="hl-editor-hint">{t('settings.highlight.profilesHint')}</span>
<Table
size="small"
rowKey="id"
columns={columns}
dataSource={profiles}
pagination={false}
locale={{ emptyText: t('settings.highlight.profileEmpty') }}
/>
<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.profileEdit') : t('settings.highlight.profileNew')}
okButtonProps={{ disabled: name.trim() === '' }}
>
<div className="hl-editor">
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.profileName')}</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('settings.highlight.profileNamePlaceholder')}
maxLength={40}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.profileRules')}</span>
<div className="hl-editor-control">
<span className="hl-editor-hint">{t('settings.highlight.profileRulesHint')}</span>
<Checkbox.Group
className="hl-profile-rules"
value={ruleIds}
onChange={(values) => setRuleIds(values as string[])}
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
/>
</div>
</div>
</div>
</Modal>
</div>
)
}
+359 -34
View File
@@ -6,15 +6,33 @@ import {
InputNumber,
Modal,
Popconfirm,
Segmented,
Select,
Switch,
Table,
Tooltip
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings'
import {
DEFAULT_HIGHLIGHT_RULES,
HIGHLIGHT_CATEGORIES,
highlightModeOf,
type HighlightCategory,
type HighlightMode
} from '@shared/settings'
import type { HighlightRule } from '@shared/settings'
import { useSettingsStore } from './store'
import { useResolvedTheme, useSettingsStore } from './store'
import { HighlightImportExport } from './HighlightImportExport'
import { HighlightProfiles } from './HighlightProfiles'
import { ruleNote } from './ruleLabel'
import { compileRules, previewSpans } from '../terminal/highlightEngine'
import {
getHighlightStats,
resetHighlightStats,
subscribeHighlightStats
} from '../terminal/highlightStats'
import { applyThemeColors } from '../theme/highlightColors'
import './highlight.css'
const ORDERED_SORT: 'ascend' = 'ascend'
@@ -22,18 +40,35 @@ const ORDERED_SORT: 'ascend' = 'ascend'
/** preset color swatches for the highlighting editor. */
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
/** Sample line for the editor preview — one line that exercises several presets. */
const SAMPLE_TEXT = 'SUCCESS 42 passed 85% HTTP/1.1 404 exit code 1 deleted 3 files 120ms rm -rf build'
/**
* Longest slice of the test text that gets previewed. A 200 KB paste would
* produce thousands of spans, and React re-renders every one of them per
* keystroke — measured at ~12 ms of highlighting plus a janky paint.
*/
const PREVIEW_LIMIT = 2000
/** clone the builtin preset set to prevent reference pollution by mutations. */
const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[]
/**
* Built-in rule notes are stored as zh-CN presets; the dictionaries carry a
* `settings.highlight.builtin.<id>` entry per built-in id. `t()` returns the key
* itself when it is missing, so fall back to the stored note in that case.
*/
function ruleNote(rule: HighlightRule): string | undefined {
const key = `settings.highlight.builtin.${rule.id}`
const translated = t(key)
return translated === key ? rule.note : translated
/** Compact duration for the stats column: µs below a millisecond, then ms. */
function formatMs(ms: number): string {
if (ms <= 0) return '0'
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms.toFixed(1)}ms`
}
/** Localised label for a rule's category, with a fallback for uncategorised rules. */
function categoryLabel(category: HighlightCategory | undefined): string {
return t(`settings.highlight.category.${category ?? 'none'}`)
}
/** Sort rank for the grouped view: known categories in fixed order, none last. */
function categoryRank(rule: HighlightRule): number {
if (rule.category === undefined) return HIGHLIGHT_CATEGORIES.length
const index = HIGHLIGHT_CATEGORIES.indexOf(rule.category)
return index < 0 ? HIGHLIGHT_CATEGORIES.length : index
}
function tryCompile(pattern: string): { ok: boolean; message?: string } {
@@ -52,6 +87,9 @@ interface RuleDraft {
priority: number
fg: string
bg?: string
bands?: { min: number; fg: string }[]
category?: HighlightCategory
caseInsensitive: boolean
note?: string
enabled: boolean
}
@@ -65,12 +103,20 @@ interface EditorState {
export function HighlightTab(): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const highlightMode = useSettingsStore((s) => highlightModeOf(s.settings.terminal.highlightMode))
const groupByCategory = useSettingsStore((s) => s.settings.terminal.highlightGroupByCategory)
const statsOn = useSettingsStore((s) => s.settings.terminal.highlightStats)
const themeColorsOn = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
const [editor, setEditor] = useState<EditorState>({ open: false })
const [ioOpen, setIoOpen] = useState(false)
const sorted = useMemo(
() => [...highlightRules].sort((a, b) => a.priority - b.priority),
[highlightRules]
)
const sorted = useMemo(() => {
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
// Grouped view: cluster by category, keeping priority order inside a group
// (Array.prototype.sort is stable, so the priority sort above survives).
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
}, [highlightRules, groupByCategory])
const openCreate = (): void => setEditor({ open: true, id: undefined })
const openEdit = (target: HighlightRule): void => setEditor({ open: true, id: target.id })
@@ -93,7 +139,52 @@ export function HighlightTab(): React.JSX.Element {
await setHighlightRules(cloneDefaults())
}
/** Only present in the grouped view, where it also explains the row order. */
const categoryColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.category'),
dataIndex: 'category',
width: 96,
align: 'center',
filters: [
...HIGHLIGHT_CATEGORIES.map((category) => ({
text: t(`settings.highlight.category.${category}`),
value: category
})),
{ text: t('settings.highlight.category.none'), value: 'none' }
],
onFilter: (value, record) => (record.category ?? 'none') === value,
render: (_category, record) => <span className="hl-cell-note">{categoryLabel(record.category)}</span>
}
const [statsTick, setStatsTick] = useState(0)
useEffect(() => subscribeHighlightStats(() => setStatsTick((n) => n + 1)), [])
const ruleStats = useMemo(() => getHighlightStats().rules, [statsTick])
/** Only present while the stats switch is on. */
const hitsColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.statsHits'),
key: 'hits',
width: 76,
align: 'center',
sorter: (a, b) => (ruleStats.get(a.id)?.hits ?? 0) - (ruleStats.get(b.id)?.hits ?? 0),
render: (_value, record) => (
<span className="hl-cell-priority">{ruleStats.get(record.id)?.hits ?? 0}</span>
)
}
const msColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.statsMs'),
key: 'ms',
width: 88,
align: 'center',
sorter: (a, b) => (ruleStats.get(a.id)?.ms ?? 0) - (ruleStats.get(b.id)?.ms ?? 0),
render: (_value, record) => (
<span className="hl-cell-priority">{formatMs(ruleStats.get(record.id)?.ms ?? 0)}</span>
)
}
const columns: ColumnsType<HighlightRule> = [
...(groupByCategory ? [categoryColumn] : []),
...(statsOn ? [hitsColumn, msColumn] : []),
{
title: t('settings.highlight.enabled'),
dataIndex: 'enabled',
@@ -107,7 +198,7 @@ export function HighlightTab(): React.JSX.Element {
title: t('settings.highlight.pattern'),
dataIndex: 'pattern',
ellipsis: true,
render: (pattern: string) => <PatternCell pattern={pattern} />
render: (_pattern: string, record) => <PatternCell rule={record} />
},
{
title: t('settings.highlight.priority'),
@@ -121,19 +212,30 @@ export function HighlightTab(): React.JSX.Element {
{
title: t('settings.preview'),
dataIndex: 'color',
width: 120,
width: 160,
align: 'center',
render: (color: HighlightRule['color']) => (
<span
className="hl-preview-text"
style={{
color: color.fg,
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
</span>
)
render: (color: HighlightRule['color'], record) =>
record.bands && record.bands.length > 0 ? (
<div className="hl-band-preview">
{record.bands.map((band) => (
<Tooltip key={band.min} title={`≥ ${band.min}`}>
<span className="hl-preview-text" style={{ color: band.fg }}>
≥{band.min}
</span>
</Tooltip>
))}
</div>
) : (
<span
className="hl-preview-text"
style={{
color: color.fg,
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
</span>
)
},
{
title: t('settings.highlight.note'),
@@ -180,6 +282,57 @@ export function HighlightTab(): React.JSX.Element {
message={t('settings.highlight.alert')}
/>
<div className="hl-toolbar">
<Tooltip title={t('settings.highlight.modeDesc')}>
<span className="hl-master">
<span className="hl-editor-sub-label">{t('settings.highlight.mode')}</span>
<Segmented
size="small"
value={highlightMode}
onChange={(value) => void updateTerminal({ highlightMode: value as HighlightMode })}
options={[
{ value: 'all', label: t('settings.highlight.modeAll') },
{ value: 'basic', label: t('settings.highlight.modeBasic') },
{ value: 'off', label: t('settings.highlight.modeOff') }
]}
/>
</span>
</Tooltip>
<Tooltip title={t('settings.highlight.groupByCategory')}>
<span className="hl-master">
<Switch
size="small"
checked={groupByCategory}
onChange={(checked) => void updateTerminal({ highlightGroupByCategory: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.groupByCategory')}</span>
</span>
</Tooltip>
<Tooltip title={t('settings.highlight.statsHint')}>
<span className="hl-master">
<Switch
size="small"
checked={statsOn}
onChange={(checked) => void updateTerminal({ highlightStats: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.stats')}</span>
</span>
</Tooltip>
{statsOn && (
<Button size="small" onClick={() => resetHighlightStats()}>
{t('settings.highlight.statsReset')}
</Button>
)}
<Tooltip title={t('settings.highlight.themeColorsHint')}>
<span className="hl-master">
<Switch
size="small"
checked={themeColorsOn}
onChange={(checked) => void updateTerminal({ highlightThemeColors: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.themeColors')}</span>
</span>
</Tooltip>
<Button onClick={() => setIoOpen(true)}>{t('settings.highlight.importExport')}</Button>
<Popconfirm
title={t('settings.highlight.resetTitle')}
description={t('settings.highlight.resetDesc')}
@@ -193,7 +346,9 @@ export function HighlightTab(): React.JSX.Element {
{t('settings.highlight.addRule')}
</Button>
</div>
<div className="hl-table">
<HighlightImportExport open={ioOpen} onClose={() => setIoOpen(false)} />
<HighlightProfiles />
<div className={highlightMode === 'off' ? 'hl-table hl-table-off' : 'hl-table'}>
<Table
size="small"
rowKey="id"
@@ -212,12 +367,17 @@ export function HighlightTab(): React.JSX.Element {
)
}
function PatternCell({ pattern }: { pattern: string }): React.JSX.Element {
const { ok, message } = tryCompile(pattern)
function PatternCell({ rule }: { rule: HighlightRule }): React.JSX.Element {
const { ok, message } = tryCompile(rule.pattern)
return (
<div className="hl-pattern-cell">
<Tooltip title={pattern}>
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{pattern}</span>
{rule.caseInsensitive === true && (
<Tooltip title={t('settings.highlight.caseInsensitive')}>
<span className="hl-case-badge">Aa</span>
</Tooltip>
)}
<Tooltip title={rule.pattern}>
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{rule.pattern}</span>
</Tooltip>
{!ok && (
<Tooltip title={message}>
@@ -243,6 +403,8 @@ function HighlightEditor({
}): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const themeColors = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
const theme = useResolvedTheme()
const isCreate = ruleId === undefined
const editing = useMemo(
@@ -255,11 +417,55 @@ function HighlightEditor({
priority: 1,
fg: '#3fb950',
bg: undefined,
caseInsensitive: false,
note: undefined,
enabled: true
})
const [saving, setSaving] = useState(false)
const [showBg, setShowBg] = useState(false)
const [testText, setTestText] = useState('')
/**
* Preview the draft rule *together with* the other rules: a span that some
* earlier rule steals (`done`, claimed by the shell-keyword rule) then shows up
* here rather than surprising the user in the terminal.
*/
const previewRules = useMemo(() => {
const draftRule: HighlightRule = {
id: ruleId ?? 'draft',
pattern: draft.pattern,
enabled: true,
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 } : {})
}
const active = [...highlightRules.filter((rule) => rule.id !== ruleId), draftRule]
// The preview has to show the colours the terminal will use, so the theme
// mapping is applied here exactly as TerminalView applies it.
return compileRules(themeColors ? applyThemeColors(active, theme) : active)
}, [
draft.pattern,
draft.priority,
draft.fg,
draft.bg,
draft.bands,
draft.caseInsensitive,
showBg,
highlightRules,
ruleId,
themeColors,
theme
])
const preview = useMemo(
() =>
previewSpans(
(testText.trim() === '' ? SAMPLE_TEXT : testText).slice(0, PREVIEW_LIMIT),
previewRules
),
[testText, previewRules]
)
// (re)initialize the form each time the modal opens
useEffect(() => {
@@ -274,7 +480,10 @@ function HighlightEditor({
const bg = editing ? editing.color.bg : undefined
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
setDraft({ pattern, priority, fg, bg, note, enabled })
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const bands = editing?.bands
const category = editing?.category
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
setShowBg(bg != null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -284,11 +493,34 @@ function HighlightEditor({
const patch = (partial: Partial<RuleDraft>): void => setDraft((prev) => ({ ...prev, ...partial }))
const addBand = (): void => {
const bands = draft.bands ?? []
const top = bands[bands.length - 1]
// the new row starts above the current top one, so it is usable as-is
patch({ bands: [...bands, { min: top ? top.min + 20 : 0, fg: PRESET_COLORS[0] }] })
}
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
const bands = draft.bands
if (!bands) return
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
}
const removeBand = (index: number): void => {
const bands = draft.bands
if (!bands) return
const next = bands.filter((_, i) => i !== index)
// dropping the last band switches the rule back to a single colour
patch({ bands: next.length > 0 ? next : undefined })
}
const handleSave = async (): Promise<void> => {
if (!valid) return
setSaving(true)
try {
const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) }
const caseInsensitive = draft.caseInsensitive ? true : undefined
const bands = draft.bands && draft.bands.length > 0 ? draft.bands : undefined
if (isCreate) {
const rule: HighlightRule = {
id: crypto.randomUUID(),
@@ -296,6 +528,9 @@ function HighlightEditor({
priority: draft.priority,
enabled: draft.enabled,
color,
bands,
category: draft.category,
caseInsensitive,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
@@ -306,6 +541,9 @@ function HighlightEditor({
priority: draft.priority,
enabled: draft.enabled,
color,
bands,
category: draft.category,
caseInsensitive,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
@@ -343,6 +581,35 @@ function HighlightEditor({
{!compile.ok && compile.message != null && (
<div className="hl-editor-error">{compile.message}</div>
)}
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.caseInsensitive}
onChange={(c) => patch({ caseInsensitive: c })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.caseInsensitive')}</span>
<span className="hl-editor-hint">{t('settings.highlight.caseInsensitiveHint')}</span>
</div>
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.testText')}</span>
<div className="hl-editor-control">
<Input.TextArea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder={SAMPLE_TEXT}
autoSize={{ minRows: 1, maxRows: 3 }}
/>
<div className="hl-preview-line">
{preview.map((span, index) => (
<span key={index} style={{ color: span.fg, background: span.bg }}>
{span.text}
</span>
))}
</div>
<span className="hl-editor-hint">{t('settings.highlight.testHint')}</span>
</div>
</div>
@@ -388,6 +655,48 @@ function HighlightEditor({
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.bands')}</span>
<div className="hl-editor-control">
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.bands != null}
onChange={(c) =>
patch({ bands: c ? (draft.bands ?? [{ min: 0, fg: draft.fg }]) : undefined })
}
/>
<span className="hl-editor-hint">{t('settings.highlight.bandsHint')}</span>
</div>
{draft.bands?.map((band, index) => (
<div className="hl-editor-inline" key={index}>
<span className="hl-editor-sub-label">{t('settings.highlight.bandMin')}</span>
<InputNumber
size="small"
value={band.min}
onChange={(v) => updateBand(index, { min: typeof v === 'number' ? v : 0 })}
style={{ width: 88 }}
/>
<ColorField value={band.fg} onChange={(v) => updateBand(index, { fg: v })} />
<Button
size="small"
title={t('settings.highlight.removeBand')}
onClick={() => removeBand(index)}
>
×
</Button>
</div>
))}
{draft.bands != null && (
<div className="hl-editor-inline">
<Button size="small" onClick={addBand}>
{t('settings.highlight.addBand')}
</Button>
</div>
)}
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.palette')}</span>
<div className="hl-palette">
@@ -413,6 +722,22 @@ function HighlightEditor({
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.category')}</span>
<Select
size="small"
allowClear
style={{ width: 150 }}
value={draft.category}
placeholder={t('settings.highlight.category.none')}
onChange={(value) => patch({ category: value ?? undefined })}
options={HIGHLIGHT_CATEGORIES.map((category) => ({
value: category,
label: t(`settings.highlight.category.${category}`)
}))}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
+117
View File
@@ -69,6 +69,19 @@
cursor: help;
}
/* marks a rule that matches without regard to letter case */
.hl-case-badge {
flex: 0 0 auto;
padding: 0 4px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 10px;
line-height: 14px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
cursor: help;
}
/* ---- misc cells ---- */
.hl-cell-priority {
@@ -96,6 +109,110 @@
min-width: 74px;
}
/* master switch sits on the left of the toolbar */
.hl-master {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: auto;
cursor: help;
}
/* rules are kept while highlighting is off — say so visually */
.hl-table-off {
opacity: 0.45;
}
/* live preview line under the test-text box */
.hl-preview-line {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
min-height: 24px;
padding: 6px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
}
/* ---- per-host rule sets ---- */
.hl-profiles {
display: flex;
flex-direction: column;
gap: 8px;
}
.hl-profiles-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* the rule picker can hold 20+ entries; keep the dialog from growing */
.hl-profile-rules {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.hl-profile-rules .ant-checkbox-wrapper {
align-items: flex-start;
font-size: 12px;
word-break: break-all;
}
/* ---- import / export dialog ---- */
.hl-io {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
}
.hl-io-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.hl-io-label {
font-size: 12px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
}
.hl-io-json textarea {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.45;
}
/* the file input is driven by the button next to it */
.hl-io-file input {
display: none;
}
/* ---- value-banded rule: one chip per threshold, in that band's colour ---- */
.hl-band-preview {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 2px 6px;
}
.hl-band-preview .hl-preview-text {
font-size: 11px;
font-weight: 500;
min-width: 0;
}
.hl-actions {
display: flex;
justify-content: center;
+23
View File
@@ -0,0 +1,23 @@
import { t } from '@shared/i18n'
import type { HighlightRule } from '@shared/settings'
/**
* Labels for a rule in the settings UI.
*
* Built-in rules store a zh-CN note and carry a `settings.highlight.builtin.<id>`
* translation; `t()` returns the key itself when a translation is missing, which
* is exactly how the two are told apart here.
*/
/** Translated note for a built-in rule, or the stored note for a user rule. */
export function ruleNote(rule: HighlightRule): string | undefined {
const key = `settings.highlight.builtin.${rule.id}`
const translated = t(key)
if (translated !== key) return translated
return rule.note !== undefined && rule.note !== '' ? rule.note : undefined
}
/** Label for a rule in a picker: its note, falling back to the pattern itself. */
export function ruleLabel(rule: HighlightRule): string {
return ruleNote(rule) ?? rule.pattern
}
+11
View File
@@ -15,6 +15,8 @@ export interface SettingsState {
setCustomThemes: (themes: AppSettings['customThemes']) => Promise<void>
/** replace highlightRules array and persist */
setHighlightRules: (rules: AppSettings['highlightRules']) => Promise<void>
/** replace highlightProfiles array and persist */
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
/** shallow-merge into settings.system and persist */
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
}
@@ -56,6 +58,9 @@ async function persist(
const patch: Partial<AppSettings> = {}
if (written.customThemes !== prev.customThemes) patch.customThemes = written.customThemes
if (written.highlightRules !== prev.highlightRules) patch.highlightRules = written.highlightRules
if (written.highlightProfiles !== prev.highlightProfiles) {
patch.highlightProfiles = written.highlightProfiles
}
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
@@ -116,6 +121,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
await persist(get, set, next, prev, 'setHighlightRules')
},
setHighlightProfiles: async (profiles) => {
const prev = get().settings
const next: AppSettings = { ...prev, highlightProfiles: profiles }
await persist(get, set, next, prev, 'setHighlightProfiles')
},
updateSystem: async (partial) => {
const prev = get().settings
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
+83 -5
View File
@@ -11,10 +11,13 @@ import { Checkbox, Modal } from 'antd'
import { t } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream } from './highlightEngine'
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats'
import { applyThemeColors } from '@renderer/theme/highlightColors'
import { findUrls } from './urlLinks'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
@@ -98,6 +101,8 @@ export interface TerminalViewProps {
className?: string
/** ssh sessions have a remote cwd — the open-directory button is hidden */
isSsh?: boolean
/** saved connection behind an ssh session, used to look up its highlight profile */
connectionId?: string
}
interface SearchBarProps {
@@ -256,7 +261,10 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
}
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className, isSsh = false }, ref) {
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner(
{ sessionId, onClose, className, isSsh = false, connectionId },
ref
) {
const settings = useSettingsStore((s) => s.settings)
const hostRef = useRef<HTMLDivElement | null>(null)
@@ -301,7 +309,75 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// the (stable, single-construction) pty/term subscriptions always read the
// latest rules without triggering a terminal rebuild. Only new output is
// highlighted — the existing scrollback buffer is left untouched.
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
//
// The mode empties the rule set instead of bypassing the stream: the stream
// holds a trailing text run back, and skipping it would drop those bytes. An
// empty set is ~free — applyHighlights returns the chunk before it tokenizes.
const highlightMode = highlightModeOf(settings.terminal.highlightMode)
const highlightTheme = useResolvedTheme()
// Per-host profiles (opt-in): an SSH session bound to a profile runs only that
// subset. The binding lives on the saved connection, so it is looked up once
// per session — and only while the feature is switched on.
const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined)
useEffect(() => {
if (connectionId === undefined || !settings.terminal.highlightPerHost) {
setBoundProfileId(undefined)
return undefined
}
let cancelled = false
void window.api
.listConnections()
.then((list) => {
if (!cancelled) {
setBoundProfileId(list.find((conn) => conn.id === connectionId)?.highlightProfileId)
}
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [connectionId, settings.terminal.highlightPerHost])
const compiledRules = useMemo(() => {
if (highlightMode === 'off') return []
const active =
highlightMode === 'basic'
? settings.highlightRules.filter((rule) => rule.basic === true)
: settings.highlightRules
const perHost = settings.terminal.highlightPerHost
? rulesForProfile(active, boundProfileId, settings.highlightProfiles)
: active
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
// colour comes from the theme the terminal is actually drawn with.
return compileRules(
settings.terminal.highlightThemeColors ? applyThemeColors(perHost, highlightTheme) : perHost
)
}, [
highlightMode,
settings.highlightRules,
settings.highlightProfiles,
settings.terminal.highlightThemeColors,
settings.terminal.highlightPerHost,
boundProfileId,
highlightTheme
])
// 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.
const statsRef = useRef<Map<string, RuleStat> | null>(null)
useEffect(() => {
const sink = settings.terminal.highlightStats
? (statsRef.current ?? new Map<string, RuleStat>())
: 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)
return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats])
streamRef.current?.setRules(compiledRules)
// M5: load completion candidates once per session (history + library cached).
@@ -767,6 +843,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// across a sessionId rebind (e.g. template apply).
const stream = new HighlightStream(compiledRules)
streamRef.current = stream
// A session created while stats are on keeps collecting into the same map.
stream.setStats(statsRef.current ?? undefined)
// The stream holds a trailing text run for cross-chunk anchor consistency.
// A shell prompt is exactly such a run with no follow-up data, so flush the
// carry on a short timer — prompts render within a frame, anchors survive.
+124 -15
View File
@@ -12,16 +12,32 @@ import type { HighlightRule } from '@shared/settings'
export interface CompiledRule {
id: string
/** compiled with the 'g' flag, pattern's own flags otherwise untouched */
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
regex: RegExp
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
fg: string
/** optional hex bg */
bg?: string
/** optional value bands, ascending by `min` — see HighlightRule.bands */
bands?: { min: number; fg: string }[]
/** copied from the source rule for stable priority ordering */
priority: number
}
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
export interface RuleStat {
/** matches that were actually coloured */
hits: number
/** wall time spent scanning with this rule, milliseconds */
ms: number
}
/**
* Optional sink for the settings page's stats view. Absent on the default path,
* so an ordinary terminal write pays nothing for the counters.
*/
export type StatsSink = Map<string, RuleStat>
type Token = { kind: 'seq' | 'text'; value: string }
type Span = { start: number; end: number; fg: string; bg?: string }
@@ -84,20 +100,36 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
if (typeof pattern !== 'string' || pattern.length === 0) continue
let regex: RegExp
try {
// 'g' always; case-sensitivity left to the pattern (no automatic 'i').
regex = new RegExp(pattern, 'g')
// 'g' always; 'i' when the rule asks for case-insensitive matching.
// 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')
} catch {
continue // invalid pattern → silently skip
}
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority })
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
}
out.sort((a, b) => a.priority - b.priority)
return out
}
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
if (!Array.isArray(bands)) return undefined
const kept: { min: number; fg: string }[] = []
for (const band of bands) {
if (!band || !Number.isFinite(band.min)) continue
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
kept.push({ min: band.min, fg: band.fg })
}
if (kept.length === 0) return undefined
kept.sort((a, b) => a.min - b.min)
return kept
}
/** Wrap one plain-text span with truecolor SGR. */
function wrap(text: string, fgHex: string, bgHex?: string): string {
const f = hexToRgb(fgHex)
@@ -117,6 +149,26 @@ function overlaps(claimed: Span[], s: number, e: number): boolean {
return false
}
/**
* Colour for one match: with value bands the match's first number picks the
* band (last `min` that is <= the value wins); everything else keeps the rule's
* own foreground.
*/
function bandColor(rule: CompiledRule, matched: string): string {
const bands = rule.bands
if (!bands || bands.length === 0) return rule.fg
const number = /-?\d+(?:\.\d+)?/.exec(matched)
if (number === null) return rule.fg
const value = Number(number[0])
if (!Number.isFinite(value)) return rule.fg
let fg = rule.fg
for (const band of bands) {
if (value < band.min) break
fg = band.fg
}
return fg
}
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
function hasLongLine(text: string): boolean {
let start = 0
@@ -134,13 +186,17 @@ function hasLongLine(text: string): boolean {
* 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).
*/
function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string {
/** 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): string {
if (text.length === 0) return ''
if (hasLongLine(text)) return text
const claimed: Span[] = []
for (let ri = 0; ri < rules.length; ri++) {
const rule = rules[ri]
if (budgets[ri] <= 0) continue
const started = stats === undefined ? 0 : now()
rule.regex.lastIndex = 0
let m: RegExpExecArray | null
let made = 0
@@ -153,13 +209,19 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
continue
}
if (!overlaps(claimed, start, end)) {
claimed.push({ start, end, fg: rule.fg, bg: rule.bg })
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
made++
budgets[ri]--
if (budgets[ri] <= 0) break
}
if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse
}
if (stats !== undefined) {
const stat = stats.get(rule.id) ?? { hits: 0, ms: 0 }
stat.hits += made
stat.ms += now() - started
stats.set(rule.id, stat)
}
}
if (claimed.length === 0) return text
claimed.sort((a, b) => a.start - b.start)
@@ -177,8 +239,10 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
/**
* Rewrite `chunk` with truecolor highlights. Returns the input unchanged when
* 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.
*/
export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: StatsSink): string {
if (!chunk || rules.length === 0) return chunk
if (chunk.length > MAX_CHUNK) return chunk
@@ -197,15 +261,54 @@ export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
continue
}
const before = budgets.reduce((a, b) => a + b, 0)
out += applyRun(token.value, rules, budgets)
out += applyRun(token.value, rules, budgets, stats)
const after = budgets.reduce((a, b) => a + b, 0)
if (after < before) usedBudget = true
}
return usedBudget ? out : chunk
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */
function isIncompleteEscape(seg: string): boolean {
/** One coloured or plain segment of a preview line. */
export interface PreviewSpan {
text: string
fg?: string
bg?: string
}
/** "#rrggbb" from three decimal SGR colour components. */
function toHex(r: string, g: string, b: string): string {
const part = (value: string): string => Number(value).toString(16).padStart(2, '0')
return `#${part(r)}${part(g)}${part(b)}`
}
/**
* Split `text` into coloured and plain segments for the settings preview.
*
* It runs the real highlighter and parses its own SGR output instead of
* re-implementing the matching, so the editor preview cannot drift from what the
* terminal actually receives. Escape sequences already in the sample are
* stripped first: this shows text the user typed, not a captured stream.
*/
export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[] {
const out = applyHighlights(text.replace(ESCAPE_RE, ''), rules)
const spans: PreviewSpan[] = []
const SGR = /\x1b\[38;2;(\d+);(\d+);(\d+)(?:;48;2;(\d+);(\d+);(\d+))?m([\s\S]*?)\x1b\[0m/g
let last = 0
let m: RegExpExecArray | null
while ((m = SGR.exec(out)) !== null) {
if (m.index > last) spans.push({ text: out.slice(last, m.index) })
spans.push({
text: m[7],
fg: toHex(m[1], m[2], m[3]),
...(m[4] !== undefined ? { bg: toHex(m[4], m[5], m[6]) } : {})
})
last = m.index + m[0].length
}
if (last < out.length) spans.push({ text: out.slice(last) })
return spans
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
// `seg` is expected to start at (or be) a lone ESC.
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
if (seg === '\x1b') return true
@@ -251,6 +354,7 @@ const MAX_CARRY = 1024
*/
export class HighlightStream {
private rules: CompiledRule[]
private stats?: StatsSink
private buffer = ''
constructor(rules: CompiledRule[]) {
@@ -262,6 +366,11 @@ export class HighlightStream {
this.rules = rules
}
/** Attach or drop the optional stats sink (see `highlightStats`). */
setStats(stats: StatsSink | undefined): void {
this.stats = stats
}
/** True while content is held back (a pending flush will release it). */
hasPending(): boolean {
return this.buffer.length > 0
@@ -280,10 +389,10 @@ export class HighlightStream {
// Give up waiting on a pathologically long unterminated sequence: emit
// the fragment raw (applyHighlights skips raw \x1b in text runs).
this.buffer = ''
return applyHighlights(head, this.rules) + tail
return applyHighlights(head, this.rules, this.stats) + tail
}
this.buffer = tail
return applyHighlights(head, this.rules)
return applyHighlights(head, this.rules, this.stats)
}
// (b) no incomplete escape -> find the last *closed* escape and emit only
@@ -295,20 +404,20 @@ export class HighlightStream {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
return applyHighlights(raw, this.rules)
return applyHighlights(raw, this.rules, this.stats)
}
return ''
}
const head = this.buffer.slice(0, lastEnd)
this.buffer = this.buffer.slice(lastEnd)
return applyHighlights(head, this.rules)
return applyHighlights(head, this.rules, this.stats)
}
/** Flush any held content (session end) — highlighted as a final run. */
flush(): string {
const b = this.buffer
this.buffer = ''
return applyHighlights(b, this.rules)
return applyHighlights(b, this.rules, this.stats)
}
}
@@ -0,0 +1,43 @@
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.
*/
let totals: Map<string, RuleStat> = new Map()
let updatedAt = 0
const listeners = new Set<() => void>()
export interface HighlightStatsSnapshot {
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
updatedAt = Date.now()
for (const listener of listeners) listener()
}
export function resetHighlightStats(): void {
publishHighlightStats(new Map())
}
export function subscribeHighlightStats(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
+87
View File
@@ -0,0 +1,87 @@
import { hexToRgb } from '../terminal/highlightEngine'
import type { HighlightRule } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
/**
* Optional "follow the theme" colour mapping for highlight rules.
*
* Rules ship with hand-picked hex colours that read on any theme, but a user on
* Solarized or Gruvbox usually wants highlighting drawn from *their* palette.
* The mapping buckets a colour by hue instead of picking the nearest swatch, so
* the intent survives: a success green stays a green, an error red stays a red.
* Brightness selects the normal or bright palette entry, which is what keeps a
* two-green value band (50–80% light green vs 80%+ green) distinguishable.
*
* Muted colours (grey-ish, like the 1xx status grey) are left untouched — there
* is no hue to follow, and the palette has no neutral that means "quieter".
*/
/** ANSI palette entries a highlight colour can land on. */
type PaletteName = 'red' | 'yellow' | 'green' | 'cyan' | 'blue' | 'magenta'
export interface PaletteMatch {
name: PaletteName
bright: boolean
}
/** Below this saturation a colour counts as neutral and keeps its own value. */
const NEUTRAL_SATURATION = 0.15
/** Above this lightness the "bright" palette entry is used. */
const BRIGHT_LIGHTNESS = 0.6
/** Bucket a colour by hue/brightness, or `null` for neutrals. */
export function paletteMatch(hex: string): PaletteMatch | null {
const { r, g, b } = hexToRgb(hex)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
if (max === 0 || (max - min) / max < NEUTRAL_SATURATION) return null
const lightness = (max + min) / 2 / 255
const delta = max - min
let hue: number
if (max === r) hue = ((g - b) / delta) % 6
else if (max === g) hue = (b - r) / delta + 2
else hue = (r - g) / delta + 4
hue = (hue * 60 + 360) % 360
const name: PaletteName =
hue < 25 || hue >= 335
? 'red'
: hue < 70
? 'yellow'
: hue < 165
? 'green'
: hue < 200
? 'cyan'
: hue < 265
? 'blue'
: 'magenta'
return { name, bright: lightness >= BRIGHT_LIGHTNESS }
}
/** The theme's colour for a rule colour, or the rule colour when it has no hue. */
export function themeColorFor(hex: string, theme: TerminalTheme): string {
const match = paletteMatch(hex)
if (match === null) return hex
const key = (
match.bright ? `bright${match.name[0].toUpperCase()}${match.name.slice(1)}` : match.name
) as keyof ThemeColors
const color = theme.colors[key]
return typeof color === 'string' && color !== '' ? color : hex
}
/**
* Rewrite a rule set onto the theme's palette. Backgrounds are left alone on
* purpose: they are a block behind the text, not a semantic signal, and mapping
* them to a palette *foreground* colour would invert their meaning.
*/
export function applyThemeColors(rules: HighlightRule[], theme: TerminalTheme): HighlightRule[] {
return rules.map((rule) => ({
...rule,
color: { fg: themeColorFor(rule.color.fg, theme), ...(rule.color.bg ? { bg: rule.color.bg } : {}) },
...(rule.bands
? { bands: rule.bands.map((band) => ({ ...band, fg: themeColorFor(band.fg, theme) })) }
: {})
}))
}
@@ -67,6 +67,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
<TerminalView
sessionId={sessionId}
isSsh={isSsh}
connectionId={params.connectionId}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
+4 -2
View File
@@ -55,6 +55,8 @@ interface TerminalParams {
sessionId?: string
sessionKind?: 'local' | 'ssh'
hostLabel?: string
/** saved SSH connection this session came from, for per-host highlight profiles */
connectionId?: string
}
export type { TerminalParams }
@@ -744,7 +746,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: conn.name,
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host, connectionId: conn.id }
})
// B's sidebar refreshes lastConnectedAt on connect.
void sidebarRef.current?.refresh()
@@ -834,7 +836,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: reference.title,
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel },
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel, connectionId: refParams?.connectionId },
position: { referencePanel: reference.id, direction }
})
},
+4
View File
@@ -35,6 +35,8 @@ export interface SshConnection {
createdAt: number
lastConnectedAt?: number
savedAuth: SshSavedAuthFlags
/** highlight profile bound to this host; absent = run every rule */
highlightProfileId?: string
}
/** Renderer-supplied connection data on save; secrets here are plaintext on purpose (one-way, encrypted before persisting). */
@@ -50,6 +52,8 @@ export interface SshConnectionInput {
askPassphraseAtConnect: boolean
keyPath?: string
keepaliveIntervalSec: number
/** highlight profile bound to this host; absent = run every rule */
highlightProfileId?: string
/** plain password to store; omit to keep the previously stored one */
password?: string
/** private key content to store inline; omit to keep the previously stored one */
+147
View File
@@ -0,0 +1,147 @@
import { isHighlightCategory, type HighlightRule } from './settings'
/**
* Import / export of highlight rule sets — share a set with a colleague, or keep
* a copy before experimenting.
*
* The envelope carries a kind + version so pasting the wrong JSON (a layout, a
* connection list) fails loudly instead of importing nonsense. Validation here
* stays deliberately light: it only rejects what cannot be a rule at all
* (unparseable JSON, no `rules` array, an unusable regex). Everything else is
* repaired by the settings store on save, which already owns that job.
*/
export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules'
export const HIGHLIGHT_FILE_VERSION = 1
/** Default colour for an imported rule that lost its own. */
const FALLBACK_FG = '#3fb950'
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
export interface HighlightFile {
kind: string
version: number
exportedAt: string
rules: HighlightRule[]
}
/** Reasons a paste can be refused, as codes the UI turns into a message. */
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind'
export interface ParsedHighlightFile {
rules: HighlightRule[]
/** One line per rule that was dropped, for the "N skipped" note. */
warnings: string[]
error?: ImportError
}
/** Serialize a rule set into the shareable envelope. */
export function exportHighlightRules(rules: HighlightRule[]): string {
const file: HighlightFile = {
kind: HIGHLIGHT_FILE_KIND,
version: HIGHLIGHT_FILE_VERSION,
exportedAt: new Date().toISOString(),
rules
}
return JSON.stringify(file, null, 2)
}
/** Id for an imported rule, so it can never collide with an existing one. */
function freshId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `imported-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
/** Accepts the exported envelope, or a bare array of rules. */
export function parseHighlightRules(text: string): ParsedHighlightFile {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return { rules: [], warnings: [], error: 'not-json' }
}
let list: unknown[]
if (Array.isArray(raw)) {
list = raw
} else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) {
const kind = (raw as { kind?: unknown }).kind
if (typeof kind === 'string' && kind !== HIGHLIGHT_FILE_KIND) {
return { rules: [], warnings: [], error: 'wrong-kind' }
}
list = (raw as { rules: unknown[] }).rules
} else {
return { rules: [], warnings: [], error: 'no-rules' }
}
const rules: HighlightRule[] = []
const warnings: string[] = []
list.forEach((entry, index) => {
const rule = coerceImported(entry, index, warnings)
if (rule) rules.push(rule)
})
return { rules, warnings }
}
/** Keep the fields we understand; the store repairs the rest on save. */
function coerceImported(value: unknown, index: number, warnings: string[]): HighlightRule | null {
if (value === null || typeof value !== 'object') {
warnings.push(`#${index + 1}: not an object`)
return null
}
const raw = value as Record<string, unknown>
const pattern = typeof raw.pattern === 'string' ? raw.pattern.trim() : ''
if (pattern === '') {
warnings.push(`#${index + 1}: no pattern`)
return null
}
try {
// eslint-disable-next-line no-new
new RegExp(pattern)
} catch {
warnings.push(`#${index + 1}: invalid regex`)
return null
}
const color = raw.color !== null && typeof raw.color === 'object' ? (raw.color as Record<string, unknown>) : null
const fg = typeof color?.fg === 'string' && HEX.test(color.fg) ? color.fg : FALLBACK_FG
const bg = typeof color?.bg === 'string' && HEX.test(color.bg) ? color.bg : undefined
const rawPriority = typeof raw.priority === 'number' ? raw.priority : Number(raw.priority)
return {
id: freshId(),
pattern,
enabled: raw.enabled !== false,
priority: Number.isFinite(rawPriority) ? Math.min(100, Math.max(1, Math.round(rawPriority))) : 10,
color: { fg, ...(bg ? { bg } : {}) },
...(Array.isArray(raw.bands) ? { bands: raw.bands as HighlightRule['bands'] } : {}),
...(raw.caseInsensitive === true ? { caseInsensitive: true } : {}),
...(raw.basic === true ? { basic: true } : {}),
...(isHighlightCategory(raw.category) ? { category: raw.category } : {}),
...(typeof raw.note === 'string' && raw.note.trim() !== '' ? { note: raw.note.trim() } : {}),
builtin: false
}
}
/** Merge imported rules into the current set: replace everything, or append. */
export function mergeRules(
existing: HighlightRule[],
incoming: HighlightRule[],
mode: 'replace' | 'append'
): HighlightRule[] {
if (mode === 'replace') return incoming
const used = new Set(existing.map((rule) => rule.id))
const appended = incoming.map((rule) => {
if (!used.has(rule.id)) {
used.add(rule.id)
return rule
}
const id = freshId()
used.add(id)
return { ...rule, id }
})
return [...existing, ...appended]
}
+83
View File
@@ -0,0 +1,83 @@
import type { HighlightProfile, HighlightRule } from './settings'
/**
* Per-host highlight profiles.
*
* A profile is a named subset of the rule list, and an SSH connection can be
* bound to one so a production host runs a quieter set than a local shell. The
* master switch (`terminal.highlightPerHost`) decides whether any of this is
* consulted: with it off, `rulesForProfile` is never called and every session
* runs the full set exactly as before.
*/
/**
* Validate a profile list coming from disk. Ids and names are required, rule
* references that no longer exist are dropped (deleting a rule must not leave a
* profile pointing at nothing), and duplicate ids are refused because the UI
* keys rows by them.
*/
export function sanitizeProfiles(
value: unknown,
knownRuleIds: Set<string>,
warn: (message: string) => void
): HighlightProfile[] {
if (!Array.isArray(value)) return []
const profiles: HighlightProfile[] = []
const seen = new Set<string>()
value.forEach((entry, index) => {
if (entry === null || typeof entry !== 'object') {
warn(`highlightProfiles[${index}]: not an object — skipped`)
return
}
const raw = entry as Record<string, unknown>
if (typeof raw.id !== 'string' || raw.id === '' || typeof raw.name !== 'string') {
warn(`highlightProfiles[${index}]: missing id/name — skipped`)
return
}
if (seen.has(raw.id)) {
warn(`highlightProfiles[${index}]: duplicate id — skipped`)
return
}
seen.add(raw.id)
const ruleIds: string[] = []
if (Array.isArray(raw.ruleIds)) {
for (const id of raw.ruleIds) {
if (typeof id !== 'string') continue
if (!knownRuleIds.has(id)) {
warn(`highlightProfiles[${index}]: unknown rule "${id}" dropped`)
continue
}
if (!ruleIds.includes(id)) ruleIds.push(id)
}
}
profiles.push({ id: raw.id, name: raw.name, ruleIds })
})
return profiles
}
/**
* The rules a session runs: the bound profile's subset, or everything when no
* profile is bound, the profile is gone, or it selects nothing.
*/
export function rulesForProfile(
rules: HighlightRule[],
profileId: string | undefined,
profiles: HighlightProfile[]
): HighlightRule[] {
if (profileId === undefined) return rules
const profile = profiles.find((candidate) => candidate.id === profileId)
if (profile === undefined || profile.ruleIds.length === 0) return rules
const wanted = new Set(profile.ruleIds)
return rules.filter((rule) => wanted.has(rule.id))
}
/** Rules a profile leaves out — shown as a hint in the editor. */
export function excludedByProfile(
rules: HighlightRule[],
profile: HighlightProfile | undefined
): HighlightRule[] {
if (profile === undefined) return []
const wanted = new Set(profile.ruleIds)
return rules.filter((rule) => !wanted.has(rule.id))
}
+77 -1
View File
@@ -117,6 +117,72 @@ const settings: Record<string, string> = {
'settings.highlight.palette': 'Palette',
'settings.highlight.note': 'Note',
'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.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',
'settings.highlight.bandMin': 'Min',
'settings.highlight.addBand': '+ Add band',
'settings.highlight.removeBand': 'Remove band',
'settings.highlight.importExport': 'Import / export',
'settings.highlight.importExportTitle': 'Import / export highlight rules',
'settings.highlight.exportSection': 'Export — copy or save this JSON to share the set',
'settings.highlight.copy': 'Copy',
'settings.highlight.copied': 'Copied',
'settings.highlight.download': 'Save as file',
'settings.highlight.importSection': 'Import — paste JSON, or pick a .json file',
'settings.highlight.chooseFile': 'Choose file',
'settings.highlight.importMode': 'Mode',
'settings.highlight.importReplace': 'Replace current rules',
'settings.highlight.importAppend': 'Append to current rules',
'settings.highlight.importOk': 'Import',
'settings.highlight.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON',
'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file',
'settings.highlight.importSkipped': 'Skipped {n}: {list}',
'settings.highlight.imported': 'Imported {n} rule(s)',
'settings.highlight.testText': 'Test text',
'settings.highlight.testHint': 'see the effect as you edit the pattern (first 2000 characters only)',
'settings.highlight.mode': 'Highlighting',
'settings.highlight.modeAll': 'All',
'settings.highlight.modeBasic': 'Basic only',
'settings.highlight.modeOff': 'Off',
'settings.highlight.modeDesc':
'Basic only: keeps just the key rules (errors, success, warnings, dangerous commands, secrets) — cheaper and quieter. Off: no colour injection at all (rules and colours are kept).',
'settings.highlight.category': 'Category',
'settings.highlight.groupByCategory': 'Group by category',
'settings.highlight.category.safety': 'Safety',
'settings.highlight.category.status': 'Status',
'settings.highlight.category.file': 'Files',
'settings.highlight.category.net': 'Network',
'settings.highlight.category.text': 'Text',
'settings.highlight.category.metric': 'Metrics',
'settings.highlight.category.none': 'Uncategorised',
'settings.highlight.stats': 'Count hits and time',
'settings.highlight.statsHint':
'Collect per-rule hit counts and durations for this page (zero cost while off)',
'settings.highlight.statsHits': 'Hits',
'settings.highlight.statsMs': 'Time',
'settings.highlight.statsReset': 'Reset stats',
'settings.highlight.statsEmpty': 'No stats yet — run some output in a terminal first',
'settings.highlight.themeColors': 'Follow theme colours',
'settings.highlight.themeColorsHint':
'Map highlight colours onto the active terminal palette by hue (neutral greys are kept as-is)',
'settings.highlight.perHost': 'Per-host rule sets',
'settings.highlight.perHostHint':
'When on, an SSH connection bound to a rule set runs only that subset; when off every session uses all rules',
'settings.highlight.profiles': 'Host rule sets',
'settings.highlight.profilesHint':
'Save a subset of the rules under a name, then bind it to a host in the connection editor',
'settings.highlight.profileNew': '+ New rule set',
'settings.highlight.profileEdit': 'Edit rule set',
'settings.highlight.profileName': 'Name',
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
'settings.highlight.profileRules': 'Included rules',
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
'settings.highlight.profileEmpty': 'No rule sets yet',
'settings.highlight.profileDeleteTitle': 'Delete rule set',
'settings.highlight.actions': 'Actions',
'settings.highlight.addRule': '+ Add rule',
'settings.highlight.empty': 'No highlight rules yet — click "+ Add rule" to create one',
@@ -129,8 +195,18 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.perm': 'Linux permissions and user/group',
'settings.highlight.builtin.path': 'Linux file paths',
'settings.highlight.builtin.shellkw': 'Shell keywords and control flow',
'settings.highlight.builtin.danger': 'Dangerous / destructive commands',
'settings.highlight.builtin.secret': 'Possible secret / token',
'settings.highlight.builtin.loglevel': 'Log levels',
'settings.highlight.builtin.rootat': 'Root prompt (root@)',
'settings.highlight.builtin.exitcode': 'Exit codes',
'settings.highlight.builtin.http': 'HTTP status codes',
'settings.highlight.builtin.latency': 'Durations (milliseconds)',
'settings.highlight.builtin.delop': 'Delete / move / overwrite operations',
'settings.highlight.builtin.createop': 'Create / add operations',
'settings.highlight.builtin.okstate': 'Success states',
'settings.highlight.builtin.badstate': 'Error/warning states',
'settings.highlight.builtin.warnstate': 'Warnings and TODO markers',
'settings.highlight.builtin.badstate': 'Error / failure states',
'settings.highlight.builtin.quoted': 'Strings and quotes',
'settings.highlight.builtin.envvar': 'Environment variables and arguments',
'settings.highlight.builtin.ipv4': 'Network and IP addresses',
+2
View File
@@ -43,6 +43,8 @@ const ssh: Record<string, string> = {
'ssh.dialog.keepalive': 'Keepalive interval (seconds)',
'ssh.dialog.keepaliveTooltip': '0 disables keepalive',
'ssh.dialog.keepaliveMin': 'Cannot be negative',
'ssh.dialog.highlightProfile': 'Highlight rule set',
'ssh.dialog.highlightProfileDefault': 'Default (all rules)',
// Connection sidebar
'ssh.sidebar.defaultGroup': 'Default',
+75 -1
View File
@@ -114,6 +114,70 @@ const settings: Record<string, string> = {
'settings.highlight.palette': 'パレット',
'settings.highlight.note': 'メモ',
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
'settings.highlight.caseInsensitive': '大文字小文字を無視',
'settings.highlight.caseInsensitiveHint': '大文字小文字を区別しない(OK は ok にも一致)',
'settings.highlight.bands': '数値の段階',
'settings.highlight.bandsHint': 'マッチ内の最初の数値で色を決めます(数値なし・最小段階未満は前景色)',
'settings.highlight.bandMin': '下限',
'settings.highlight.addBand': '+ 段階を追加',
'settings.highlight.removeBand': '段階を削除',
'settings.highlight.importExport': 'インポート / エクスポート',
'settings.highlight.importExportTitle': 'ハイライトルールのインポート / エクスポート',
'settings.highlight.exportSection': 'エクスポート(下の JSON をコピー/保存して共有)',
'settings.highlight.copy': 'コピー',
'settings.highlight.copied': 'コピーしました',
'settings.highlight.download': 'ファイルとして保存',
'settings.highlight.importSection': 'インポート(JSON を貼り付け、または .json を選択)',
'settings.highlight.chooseFile': 'ファイルを選択',
'settings.highlight.importMode': '取り込み方法',
'settings.highlight.importReplace': '現在のルールを置き換える',
'settings.highlight.importAppend': '現在のルールに追加する',
'settings.highlight.importOk': 'インポート',
'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません',
'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません',
'settings.highlight.importSkipped': '{n} 件をスキップ:{list}',
'settings.highlight.imported': '{n} 件のルールを取り込みました',
'settings.highlight.testText': 'テスト文字列',
'settings.highlight.testHint': '正規表現を編集すると即座に反映されます(先頭 2000 文字のみ)',
'settings.highlight.mode': 'ハイライト',
'settings.highlight.modeAll': 'すべて',
'settings.highlight.modeBasic': '基本のみ',
'settings.highlight.modeOff': 'オフ',
'settings.highlight.modeDesc':
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '分類でグループ化',
'settings.highlight.category.safety': '安全',
'settings.highlight.category.status': 'ステータス',
'settings.highlight.category.file': 'ファイル',
'settings.highlight.category.net': 'ネットワーク',
'settings.highlight.category.text': 'テキスト',
'settings.highlight.category.metric': '数値',
'settings.highlight.category.none': '未分類',
'settings.highlight.stats': 'ヒット数と所要時間を計測',
'settings.highlight.statsHint': 'ルールごとのヒット数と所要時間を計測します(オフのときはコストゼロ)',
'settings.highlight.statsHits': 'ヒット',
'settings.highlight.statsMs': '所要時間',
'settings.highlight.statsReset': '統計をリセット',
'settings.highlight.statsEmpty': 'まだ統計がありません。端末で出力を流してください',
'settings.highlight.themeColors': 'テーマの配色に合わせる',
'settings.highlight.themeColorsHint':
'色相でハイライト色を現在の端末パレットに割り当てます(無彩色はそのまま)',
'settings.highlight.perHost': 'ホスト別ルールセット',
'settings.highlight.perHostHint':
'オンにすると、ルールセットを紐付けた SSH 接続はそのサブセットのみ適用されます。オフでは全セッションが全ルールを使います',
'settings.highlight.profiles': 'ホスト用ルールセット',
'settings.highlight.profilesHint':
'ルールのサブセットに名前を付けて保存し、接続の編集でホストに紐付けます',
'settings.highlight.profileNew': '+ ルールセットを作成',
'settings.highlight.profileEdit': 'ルールセットを編集',
'settings.highlight.profileName': '名前',
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
'settings.highlight.profileRules': '含めるルール',
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ ルールを追加',
'settings.highlight.empty': 'ハイライトルールがありません。「+ ルールを追加」から作成できます',
@@ -126,8 +190,18 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.perm': 'Linux のパーミッションとユーザー',
'settings.highlight.builtin.path': 'Linux のファイルパス',
'settings.highlight.builtin.shellkw': 'Shell のキーワードと制御構文',
'settings.highlight.builtin.danger': '危険・破壊的なコマンド',
'settings.highlight.builtin.secret': 'シークレット・トークンの疑い',
'settings.highlight.builtin.loglevel': 'ログレベル',
'settings.highlight.builtin.rootat': 'root プロンプト(root@)',
'settings.highlight.builtin.exitcode': '終了コード',
'settings.highlight.builtin.http': 'HTTP ステータスコード',
'settings.highlight.builtin.latency': '所要時間(ミリ秒)',
'settings.highlight.builtin.delop': '削除・移動・上書きの操作',
'settings.highlight.builtin.createop': '作成・追加の操作',
'settings.highlight.builtin.okstate': '成功ステータス',
'settings.highlight.builtin.badstate': 'エラー・警告ステータス',
'settings.highlight.builtin.warnstate': '警告と TODO マーカー',
'settings.highlight.builtin.badstate': 'エラー・失敗ステータス',
'settings.highlight.builtin.quoted': '文字列と引用符',
'settings.highlight.builtin.envvar': '環境変数と引数',
'settings.highlight.builtin.ipv4': 'ネットワークと IP アドレス',
+2
View File
@@ -45,6 +45,8 @@ const ssh: Record<string, string> = {
'ssh.dialog.keepalive': 'Keepalive 間隔(秒)',
'ssh.dialog.keepaliveTooltip': '0 で keepalive を無効にします',
'ssh.dialog.keepaliveMin': '負の値は指定できません',
'ssh.dialog.highlightProfile': 'ハイライトルールセット',
'ssh.dialog.highlightProfileDefault': '既定(すべてのルール)',
// 接続サイドバー
'ssh.sidebar.defaultGroup': 'デフォルト',
+71 -1
View File
@@ -111,6 +111,66 @@ const settings: Record<string, string> = {
'settings.highlight.palette': '色板',
'settings.highlight.note': '备注',
'settings.highlight.notePlaceholder': '规则说明(可选)',
'settings.highlight.caseInsensitive': '忽略大小写',
'settings.highlight.caseInsensitiveHint': '字母不区分大小写(OK 也能匹配 ok)',
'settings.highlight.bands': '数值分级',
'settings.highlight.bandsHint': '按匹配到的第一个数字取色;没有数字或低于最小分级时用前景色',
'settings.highlight.bandMin': '下限',
'settings.highlight.addBand': '+ 添加分级',
'settings.highlight.removeBand': '删除分级',
'settings.highlight.importExport': '导入 / 导出',
'settings.highlight.importExportTitle': '导入 / 导出高亮规则',
'settings.highlight.exportSection': '导出(复制或保存下面的 JSON 分享给别人)',
'settings.highlight.copy': '复制',
'settings.highlight.copied': '已复制',
'settings.highlight.download': '保存为文件',
'settings.highlight.importSection': '导入(粘贴 JSON,或选择 .json 文件)',
'settings.highlight.chooseFile': '选择文件',
'settings.highlight.importMode': '导入方式',
'settings.highlight.importReplace': '替换现有规则',
'settings.highlight.importAppend': '追加到现有规则',
'settings.highlight.importOk': '导入',
'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件',
'settings.highlight.importSkipped': '已跳过 {n} 条:{list}',
'settings.highlight.imported': '已导入 {n} 条规则',
'settings.highlight.testText': '测试文本',
'settings.highlight.testHint': '改完正则立刻看到效果(仅预览前 2000 字符)',
'settings.highlight.mode': '高亮模式',
'settings.highlight.modeAll': '全部',
'settings.highlight.modeBasic': '仅基础',
'settings.highlight.modeOff': '关闭',
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
'settings.highlight.category': '分类',
'settings.highlight.groupByCategory': '按分类分组',
'settings.highlight.category.safety': '安全',
'settings.highlight.category.status': '状态',
'settings.highlight.category.file': '文件',
'settings.highlight.category.net': '网络',
'settings.highlight.category.text': '文本',
'settings.highlight.category.metric': '数值',
'settings.highlight.category.none': '未分类',
'settings.highlight.stats': '统计命中与耗时',
'settings.highlight.statsHint': '开启后统计每条规则的命中次数与耗时(设置页显示,关闭时零开销)',
'settings.highlight.statsHits': '命中',
'settings.highlight.statsMs': '耗时',
'settings.highlight.statsReset': '重置统计',
'settings.highlight.statsEmpty': '还没有统计数据,先去终端里跑点输出',
'settings.highlight.themeColors': '跟随主题配色',
'settings.highlight.themeColorsHint': '按色相把高亮颜色映射到当前终端主题的调色板(灰阶色保持原样)',
'settings.highlight.perHost': '按主机规则集',
'settings.highlight.perHostHint': '开启后,绑定了规则集的 SSH 连接只跑该子集;关闭时所有会话都用全部规则',
'settings.highlight.profiles': '主机规则集',
'settings.highlight.profilesHint': '把规则的子集存成命名规则集,在「连接编辑」里绑定给某台主机',
'settings.highlight.profileNew': '+ 新建规则集',
'settings.highlight.profileEdit': '编辑规则集',
'settings.highlight.profileName': '名称',
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
'settings.highlight.profileRules': '包含的规则',
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
'settings.highlight.profileEmpty': '尚未创建规则集',
'settings.highlight.profileDeleteTitle': '删除规则集',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ 新增规则',
'settings.highlight.empty': '暂无高亮规则,点击「+ 新增规则」创建',
@@ -123,8 +183,18 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.perm': 'Linux 权限与用户',
'settings.highlight.builtin.path': 'Linux 文件路径',
'settings.highlight.builtin.shellkw': 'Shell 关键字与流程',
'settings.highlight.builtin.danger': '危险/破坏性命令',
'settings.highlight.builtin.secret': '疑似密钥/令牌',
'settings.highlight.builtin.loglevel': '日志级别',
'settings.highlight.builtin.rootat': '管理员提示符 root@',
'settings.highlight.builtin.exitcode': '退出码',
'settings.highlight.builtin.http': 'HTTP 状态码',
'settings.highlight.builtin.latency': '耗时(毫秒)',
'settings.highlight.builtin.delop': '删除/移动/覆盖操作',
'settings.highlight.builtin.createop': '新建/创建操作',
'settings.highlight.builtin.okstate': '成功状态',
'settings.highlight.builtin.badstate': '错误/警告状态',
'settings.highlight.builtin.warnstate': '警告与待办标记',
'settings.highlight.builtin.badstate': '错误/失败状态',
'settings.highlight.builtin.quoted': '字符串与引号',
'settings.highlight.builtin.envvar': '环境变量与参数',
'settings.highlight.builtin.ipv4': '网络与 IP 地址',
+2
View File
@@ -41,6 +41,8 @@ const ssh: Record<string, string> = {
'ssh.dialog.keepalive': 'Keepalive 间隔(秒)',
'ssh.dialog.keepaliveTooltip': '0 表示关闭 keepalive',
'ssh.dialog.keepaliveMin': '不能为负数',
'ssh.dialog.highlightProfile': '高亮规则集',
'ssh.dialog.highlightProfileDefault': '默认(全部规则)',
// 连接侧栏
'ssh.sidebar.defaultGroup': '默认',
+71 -1
View File
@@ -111,6 +111,66 @@ const settings: Record<string, string> = {
'settings.highlight.palette': '色盤',
'settings.highlight.note': '備註',
'settings.highlight.notePlaceholder': '規則說明(選填)',
'settings.highlight.caseInsensitive': '忽略大小寫',
'settings.highlight.caseInsensitiveHint': '字母不區分大小寫(OK 也能匹配 ok)',
'settings.highlight.bands': '數值分級',
'settings.highlight.bandsHint': '依匹配到的第一個數字取色;沒有數字或低於最小分級時用前景色',
'settings.highlight.bandMin': '下限',
'settings.highlight.addBand': '+ 新增分級',
'settings.highlight.removeBand': '刪除分級',
'settings.highlight.importExport': '匯入 / 匯出',
'settings.highlight.importExportTitle': '匯入 / 匯出高亮規則',
'settings.highlight.exportSection': '匯出(複製或儲存下面的 JSON 分享給別人)',
'settings.highlight.copy': '複製',
'settings.highlight.copied': '已複製',
'settings.highlight.download': '儲存為檔案',
'settings.highlight.importSection': '匯入(貼上 JSON,或選擇 .json 檔案)',
'settings.highlight.chooseFile': '選擇檔案',
'settings.highlight.importMode': '匯入方式',
'settings.highlight.importReplace': '取代現有規則',
'settings.highlight.importAppend': '附加到現有規則',
'settings.highlight.importOk': '匯入',
'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案',
'settings.highlight.importSkipped': '已略過 {n} 條:{list}',
'settings.highlight.imported': '已匯入 {n} 條規則',
'settings.highlight.testText': '測試文字',
'settings.highlight.testHint': '改完正規表示式立刻看到效果(僅預覽前 2000 字元)',
'settings.highlight.mode': '高亮模式',
'settings.highlight.modeAll': '全部',
'settings.highlight.modeBasic': '僅基礎',
'settings.highlight.modeOff': '關閉',
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '依分類分組',
'settings.highlight.category.safety': '安全',
'settings.highlight.category.status': '狀態',
'settings.highlight.category.file': '檔案',
'settings.highlight.category.net': '網路',
'settings.highlight.category.text': '文字',
'settings.highlight.category.metric': '數值',
'settings.highlight.category.none': '未分類',
'settings.highlight.stats': '統計命中與耗時',
'settings.highlight.statsHint': '開啟後統計每條規則的命中次數與耗時(設定頁顯示,關閉時零開銷)',
'settings.highlight.statsHits': '命中',
'settings.highlight.statsMs': '耗時',
'settings.highlight.statsReset': '重設統計',
'settings.highlight.statsEmpty': '還沒有統計資料,先去終端機跑點輸出',
'settings.highlight.themeColors': '跟隨主題配色',
'settings.highlight.themeColorsHint': '依色相把高亮顏色對應到目前終端機主題的調色盤(灰階色保持原樣)',
'settings.highlight.perHost': '依主機規則集',
'settings.highlight.perHostHint': '開啟後,綁定了規則集的 SSH 連線只跑該子集;關閉時所有工作階段都用全部規則',
'settings.highlight.profiles': '主機規則集',
'settings.highlight.profilesHint': '把規則的子集存成命名規則集,在「編輯連線」裡綁定給某台主機',
'settings.highlight.profileNew': '+ 新增規則集',
'settings.highlight.profileEdit': '編輯規則集',
'settings.highlight.profileName': '名稱',
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
'settings.highlight.profileRules': '包含的規則',
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
'settings.highlight.profileEmpty': '尚未建立規則集',
'settings.highlight.profileDeleteTitle': '刪除規則集',
'settings.highlight.actions': '操作',
'settings.highlight.addRule': '+ 新增規則',
'settings.highlight.empty': '尚無高亮規則,點擊「+ 新增規則」建立',
@@ -123,8 +183,18 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.perm': 'Linux 權限與使用者',
'settings.highlight.builtin.path': 'Linux 檔案路徑',
'settings.highlight.builtin.shellkw': 'Shell 關鍵字與流程',
'settings.highlight.builtin.danger': '危險/破壞性命令',
'settings.highlight.builtin.secret': '疑似密鑰/權杖',
'settings.highlight.builtin.loglevel': '日誌等級',
'settings.highlight.builtin.rootat': '管理員提示字元 root@',
'settings.highlight.builtin.exitcode': '結束碼',
'settings.highlight.builtin.http': 'HTTP 狀態碼',
'settings.highlight.builtin.latency': '耗時(毫秒)',
'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作',
'settings.highlight.builtin.createop': '新建/建立操作',
'settings.highlight.builtin.okstate': '成功狀態',
'settings.highlight.builtin.badstate': '錯誤/警告狀態',
'settings.highlight.builtin.warnstate': '警告與待辦標記',
'settings.highlight.builtin.badstate': '錯誤/失敗狀態',
'settings.highlight.builtin.quoted': '字串與引號',
'settings.highlight.builtin.envvar': '環境變數與參數',
'settings.highlight.builtin.ipv4': '網路與 IP 位址',
+2
View File
@@ -41,6 +41,8 @@ const ssh: Record<string, string> = {
'ssh.dialog.keepalive': 'Keepalive 間隔(秒)',
'ssh.dialog.keepaliveTooltip': '0 表示關閉 keepalive',
'ssh.dialog.keepaliveMin': '不能為負數',
'ssh.dialog.highlightProfile': '高亮規則集',
'ssh.dialog.highlightProfileDefault': '預設(全部規則)',
// 連線側欄
'ssh.sidebar.defaultGroup': '預設',
+295 -8
View File
@@ -1,6 +1,23 @@
import type { TerminalTheme } from './theme'
import type { Language } from './i18n'
/** Coarse grouping for the settings table's grouped view. */
export type HighlightCategory = 'safety' | 'status' | 'file' | 'net' | 'text' | 'metric'
/** Stable order for the category column, the filter list and the editor select. */
export const HIGHLIGHT_CATEGORIES: HighlightCategory[] = [
'safety',
'status',
'file',
'net',
'text',
'metric'
]
export function isHighlightCategory(value: unknown): value is HighlightCategory {
return typeof value === 'string' && (HIGHLIGHT_CATEGORIES as string[]).includes(value)
}
/** One terminal keyword-highlight rule (regex → ANSI color injection). */
export interface HighlightRule {
id: string
@@ -11,6 +28,21 @@ export interface HighlightRule {
priority: number
/** truecolor hex (#rrggbb); fg required, bg optional */
color: { fg: string; bg?: string }
/**
* Value bands: the first number found in the match picks the colour, lowest
* `min` first. `fg` above stays the fallback for matches without a number or
* below the smallest `min`.
*/
bands?: { min: number; fg: string }[]
/** match without regard to letter case (compiled with the 'i' flag) */
caseInsensitive?: boolean
/**
* Part of the "basic" set: still applied when the highlight mode is
* `basic` (the cheap, low-noise subset — status, danger, secrets).
*/
basic?: boolean
/** Grouping shown by the settings table's category column / grouped view. */
category?: HighlightCategory
note?: string
/** part of the factory preset set (restored by 恢复默认) */
builtin?: boolean
@@ -49,12 +81,50 @@ export interface TerminalSettings {
showOpenCwdButton: boolean
/** accent color for the active tab outline, rail indicator and SSH badges */
tabAccentColor: string
/**
* How much keyword highlighting runs: `all` uses every enabled rule, `basic`
* only the rules flagged `basic` (status, danger, secrets), `off` injects no
* colour at all. The rule set itself is never touched by this.
*/
highlightMode: HighlightMode
/**
* Settings-table view: cluster rules by category (adds a category column and
* sorts by it) instead of listing them in plain priority order.
*/
highlightGroupByCategory: boolean
/**
* Collect per-rule hit counts and timings for the settings page. Off by
* default: the engine skips all counting unless a sink is attached.
*/
highlightStats: boolean
/**
* Draw highlight colours from the active terminal palette (by hue) instead of
* the rule's own hex. Off by default: the shipped colours already read on
* every theme, this is for users who want palette-consistent highlighting.
*/
highlightThemeColors: boolean
/**
* Consult per-host highlight profiles: an SSH connection bound to a profile
* runs only that profile's rules. Off by default — with it off every session
* runs the full rule set, exactly as before.
*/
highlightPerHost: boolean
/** absolute path to an image rendered behind the terminal; '' = none */
backgroundImage: string
/** background image layer opacity, 10..100 (%) */
backgroundImageOpacity: number
}
export type HighlightMode = 'all' | 'basic' | 'off'
/**
* Read a stored mode, tolerating anything a hand-edited settings.json holds: an
* unknown value behaves like `all`, the default before modes existed.
*/
export function highlightModeOf(value: unknown): HighlightMode {
return value === 'basic' || value === 'off' ? value : 'all'
}
export interface SystemSettings {
/** register the app to launch at OS login */
launchAtLogin: boolean
@@ -81,12 +151,25 @@ export interface SystemSettings {
language?: Language
}
/**
* A named subset of the highlight rules, bound to an SSH connection so a
* production host can run a quieter set than a local shell. An empty `ruleIds`
* means "everything", the same as not binding a profile at all.
*/
export interface HighlightProfile {
id: string
name: string
ruleIds: string[]
}
export interface AppSettings {
terminal: TerminalSettings
/** user-created themes; builtin themes are immutable and live in shared/theme.ts */
customThemes: TerminalTheme[]
/** terminal keyword highlight rules, applied at write time in priority order */
highlightRules: HighlightRule[]
/** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */
highlightProfiles: HighlightProfile[]
system: SystemSettings
}
@@ -96,23 +179,221 @@ const RULE = (
priority: number,
fg: string,
note: string,
bg?: string
): HighlightRule => ({ id, pattern, enabled: true, priority, color: { fg, bg }, note, builtin: true })
opts?: {
bg?: string
caseInsensitive?: boolean
bands?: HighlightRule['bands']
basic?: boolean
}
): HighlightRule => ({
id,
pattern,
enabled: true,
priority,
color: { fg, bg: opts?.bg },
note,
// omitted (not `false`) unless set, so a preset round trip stays compact
...(opts?.caseInsensitive ? { caseInsensitive: true } : {}),
...(opts?.bands ? { bands: opts.bands } : {}),
...(opts?.basic ? { basic: true } : {}),
builtin: true
})
/**
* Factory preset rules.
*
* Status words are matched case-insensitively (`\bOK\b` should fire on `ok` as
* well as `OK`), but always behind word boundaries: `invalid` must not light up
* `valid`, `disabled` must not light up `enabled`. The `okstate` pattern carries
* a `not ` lookbehind so the `ok` in `not ok` (TAP, mocha, pytest summaries) is
* left for `badstate` to claim instead of turning it green.
*
* Priorities put the destructive-command and file-operation rules above the
* status words, and the status words above `shellkw`: `done` is a shell keyword
* and a success word, `if` sits inside `dd if=`, and `drop table` belongs to
* `danger` rather than the plain `drop` of `delop`. Whichever rule runs first
* paints the span and the other one is skipped, so that order is the whole
* contract between them.
*
* Keyword stems carry their own endings rather than a generic suffix group:
* `DELET(?:E|ED|ES|ING|ION)` instead of `DELETE(?:D|S|ING)?`, which silently
* misses `deleting`. Stems that are not words on their own (MOV, SAV, CLON,
* PURG, ERAS, WIP, REVOK) always demand an ending, so a bare `MOV` in an
* assembly listing is left alone.
*
* `percent` carries value bands instead of one colour: the number in the match
* picks the band (below 20% red, 20–50% yellow, 50–80% light green, 80%+ green),
* 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('path', '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)', 2, '#58a6ff', 'Linux 文件路径'),
RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 3, '#d2a8ff', 'Shell 关键字与流程'),
RULE('okstate', '\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b', 5, '#3fb950', '成功状态'),
RULE('badstate', '\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b', 6, '#f85149', '错误/警告状态'),
RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 8, '#e3b341', '字符串与引号'),
RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 10, '#79c0ff', '环境变量与参数'),
RULE(
'danger',
'\\brm\\s+-(?:[a-z]*[rf][a-z]*)\\b|\\bmkfs(?:\\.[a-z0-9]+)?\\b|\\bdd\\s+if(?==)|\\bchmod\\s+(?:-R\\s+)?0?777\\b|\\b(?:shutdown|reboot|poweroff|halt)\\b|\\bgit\\s+(?:push\\s+(?:-f|--force)|reset\\s+--hard)\\b|\\bdrop\\s+(?:database|table)\\b|\\|\\s*(?:sudo\\s+)?(?:sh|bash|zsh|dash|ksh|python[0-9.]*|perl|ruby|node)\\b|\\beval\\b|\\bbase64\\s+(?:-d|--decode)\\b|\\bchmod\\s+\\+x\\b',
3,
'#ff7b72',
'危险/破坏性命令',
{ caseInsensitive: true }
),
RULE(
'delop',
'\\b(?:DELET(?:E|ED|ES|ING|ION)|REMOV(?:E|ED|ES|ING|AL)|RM|RMDIR|UNLINK(?:ED|S|ING)?|ERAS(?:E|ED|ES|ING)|PURG(?:E|ED|ES|ING)|DESTROY(?:ED|S|ING)?|TRUNCAT(?:E|ED|ES|ING|ION)|WIP(?:E|ED|ES|ING)|DISCARD(?:ED|S|ING)?|REVOK(?:E|ED|ES|ING)|UNMOUNT(?:ED|S|ING)?|KILL(?:ED|S|ING)?|TERMINAT(?:E|ED|ES|ING|ION)|DROP(?:PED|S|PING)?|CLEANUP|SHRED(?:DED|DING)?|MOV(?:E|ED|ES|ING)|RENAM(?:E|ED|ES|ING)|OVERWRIT(?:E|TEN|ING|ES)?)\\b',
4,
'#ffa657',
'删除/移动/覆盖操作',
{ caseInsensitive: true }
),
RULE(
'createop',
'\\b(?:CREAT(?:E|ED|ES|ING|ION)|MKDIR|TOUCH(?:ED|ES|ING)?|ADD(?:ED|S|ING)?|INSTALL(?:ED|S|ING)?|CLON(?:E|ED|ES|ING)|GENERAT(?:E|ED|ES|ING|ION)|INIT(?:IALIZ(?:E|ED|ES|ING))?|IMPORT(?:ED|S|ING)?|UPLOAD(?:ED|S|ING)?|DOWNLOAD(?:ED|S|ING)?|SAV(?:E|ED|ES|ING)|WROTE|WRITTEN|MOUNT(?:ED|S|ING)?)\\b',
5,
'#56d364',
'新建/创建操作',
{ caseInsensitive: true }
),
RULE(
'okstate',
'(?<!not\\s)\\b(?:SUCCESS(?:FUL(?:LY)?)?|SUCCEED(?:S|ED|ING)?|PASS(?:ED|ES|ING)?|OK(?:AY)?|DONE|FINISH(?:ED|ES|ING)?|COMPLET(?:E|ED|ES|ING|ION)|GOOD|FINE|TRUE|ENABL(?:E|ED|ES|ING)|ACTIVE|ONLINE|READY|HEALTHY|MATCH(?:ED|ES|ING)?|FOUND|ACCEPT(?:ED|S|ING)?|APPROV(?:E|ED|ES|ING|AL)|VALID|ALLOW(?:ED|S|ING)?)\\b|[\\u2713\\u2714\\u2705]\\uFE0F?',
6,
'#3fb950',
'成功状态',
{ caseInsensitive: true }
),
RULE(
'warnstate',
'\\b(?:WARN(?:S|ED|ING|INGS)?|CAUTION|ATTENTION|NOTICE|DEPRECAT(?:E|ED|ES|ING|ION)|OBSOLETE|UNSTABLE|TODO|FIXME|XXX|HACK|TBD)\\b|\\u26A0\\uFE0F?',
7,
'#e3b341',
'警告与待办标记',
{ caseInsensitive: true }
),
RULE(
'badstate',
'\\b(?:ERROR|ERR|ERRNO|EXCEPTION|TRACEBACK|FAIL(?:ED|S|URE|URES|ING)?|FATAL|CRITICAL|PANIC|ABORT(?:ED|S|ING)?|CRASH(?:ED|ES|ING)?|BUG(?:S|GY)?|SEGFAULT|SEGV|OOM|DENIED|REFUS(?:E|ED|ES|ING|AL)|REJECT(?:ED|S|ING)?|INVALID|ILLEGAL|MISMATCH(?:ED|ES)?|TIMEOUT|TIMED\\s+OUT|UNREACHABLE|UNHEALTHY|CONFLICT(?:ED|S|ING)?|VIOLAT(?:ED|ES|ING|ION)|CORRUPT(?:ED|S|ING|ION)?|MISSING|NOT\\s+(?:OK|DONE|PASSED|FOUND|READY|ACTIVE|RUNNING)|NG|BAD|WRONG|OFFLINE)\\b|[\\u2717\\u2718\\u2716\\u274C]\\uFE0F?',
8,
'#f85149',
'错误/失败状态',
{ caseInsensitive: true }
),
RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 9, '#d2a8ff', 'Shell 关键字与流程'),
RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 10, '#e3b341', '字符串与引号'),
RULE('loglevel', '\\b(?:INFO|DEBUG|TRACE|VERBOSE)\\b', 11, '#79c0ff', '日志级别'),
RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 12, '#79c0ff', '环境变量与参数'),
RULE('rootat', '\\broot@[A-Za-z0-9_.-]+', 13, '#ffa657', '管理员提示符 root@'),
RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'),
RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'),
RULE(
'exitcode',
'\\bexit(?:ed)?\\s+(?:with\\s+)?(?:code|status)\\s*[:=]?\\s*\\d+|\\bexit\\s+\\d{1,3}\\b',
21,
'#3fb950',
'退出码',
{
bands: [
{ min: 0, fg: '#3fb950' },
{ min: 1, fg: '#f85149' }
]
}
),
RULE(
'percent',
'\\b\\d{1,3}(?:\\.\\d+)?\\s?%',
22,
'#3fb950',
'百分比与进度',
{
bands: [
{ min: 0, fg: '#f85149' },
{ min: 20, fg: '#e3b341' },
{ min: 50, fg: '#7ee787' },
{ min: 80, fg: '#3fb950' }
]
}
),
RULE(
'http',
'(?<=\\bHTTP/\\d(?:\\.\\d)?\\s)[1-5]\\d\\d\\b|(?<=\\b(?:status|code|http_code|status_code)\\s*[:=]?\\s)[1-5]\\d\\d\\b|\\b[1-5]\\d\\d(?=\\s+(?:OK|Created|Accepted|No Content|Moved Permanently|Found|Not Modified|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Conflict|Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\\b)',
23,
'#58a6ff',
'HTTP 状态码',
{
bands: [
{ min: 100, fg: '#8b949e' },
{ min: 200, fg: '#3fb950' },
{ min: 300, fg: '#58a6ff' },
{ min: 400, fg: '#e3b341' },
{ min: 500, fg: '#f85149' }
]
}
),
RULE(
'latency',
'\\b\\d+(?:\\.\\d+)?\\s?(?:ms|msec|µs|us|ns)\\b',
24,
'#3fb950',
'耗时(毫秒)',
{
bands: [
{ min: 0, fg: '#3fb950' },
{ min: 100, fg: '#e3b341' },
{ min: 500, fg: '#f85149' }
]
}
),
RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'),
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接')
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接'),
RULE(
'secret',
'\\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',
'疑似密钥/令牌'
)
]
/**
* Rules that keep working in the `basic` highlight mode: the cheap, high-signal
* ones — failures, successes, warnings, destructive commands, leaked secrets.
* Everything else (paths, numbers, URLs, network, timings) is dropped there,
* which is what makes that mode both quieter and roughly 3x cheaper.
*/
const BASIC_RULE_IDS = new Set(['danger', 'secret', 'okstate', 'warnstate', 'badstate'])
/**
* Category per preset id, for the settings table's grouped view. One table
* rather than a field on each rule, so the whole taxonomy reads at a glance —
* and so `basic` stays a strict subset of the safety + status groups.
*/
const PRESET_CATEGORIES: Record<string, HighlightCategory> = {
danger: 'safety',
secret: 'safety',
okstate: 'status',
warnstate: 'status',
badstate: 'status',
loglevel: 'status',
exitcode: 'status',
rootat: 'status',
percent: 'metric',
latency: 'metric',
numbers: 'metric',
ipv4: 'net',
url: 'net',
http: 'net',
perm: 'file',
path: 'file',
delop: 'file',
createop: 'file',
shellkw: 'text',
quoted: 'text',
envvar: 'text',
datetime: 'text'
}
for (const rule of DEFAULT_HIGHLIGHT_RULES) {
if (BASIC_RULE_IDS.has(rule.id)) rule.basic = true
const category = PRESET_CATEGORIES[rule.id]
if (category !== undefined) rule.category = category
}
export const DEFAULT_SETTINGS: AppSettings = {
terminal: {
fontFamily: "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace",
@@ -137,10 +418,16 @@ export const DEFAULT_SETTINGS: AppSettings = {
showOpenLogsButton: false,
showOpenCwdButton: true,
tabAccentColor: '#3fb950',
highlightMode: 'all',
highlightGroupByCategory: false,
highlightStats: false,
highlightThemeColors: false,
highlightPerHost: false,
backgroundImage: '',
backgroundImageOpacity: 60
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
highlightProfiles: [],
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' }
}
+3 -1
View File
@@ -24,7 +24,9 @@ const BUNDLES = [
// Sentry from the same library.
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
// The smoke test imports the renderer engine (.ts), so it needs bundling too.
{ entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' }
{ entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' },
// Preset-rule assertions (word boundaries, case flag) over the same engine.
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' }
]
for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) {
+743
View File
@@ -0,0 +1,743 @@
/**
* Preset highlight-rule self-test (hl-rules.mjs).
*
* The shipped presets are regexes over arbitrary program output, so their word
* boundaries are the whole game: `invalid` must not light up `valid`,
* `disabled` must not light up `enabled`, and the `ok` in `not ok` must stay out
* of the success rule. Every assertion goes through the real engine, so it also
* covers the ANSI wrapping a terminal actually receives.
*
* Build + run:
* npx esbuild tests/hl-rules.mjs --bundle --platform=node --format=cjs \
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights } 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 { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
import {
excludedByProfile,
rulesForProfile,
sanitizeProfiles
} from '../src/shared/highlightProfiles.ts'
import {
applyThemeColors,
paletteMatch,
themeColorFor
} from '../src/renderer/src/theme/highlightColors.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed++
}
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
/** truecolor prefix for a hex colour, as the engine emits it */
const sgr = (hex) => {
const n = parseInt(hex.slice(1), 16)
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`
}
const OK_GREEN = '#3fb950'
const BAD_RED = '#f85149'
const WARN_YELLOW = '#e3b341'
const DANGER_RED = '#ff7b72'
const DEL_ORANGE = '#ffa657'
const NEW_GREEN = '#56d364'
/** true when `word` comes back from `text` wrapped in `hex` */
const colored = (text, hex, word) => applyHighlights(text, rules).includes(`${sgr(hex)}${word}\x1b[0m`)
// ---- 1. presets compile at all ----------------------------------------------
console.log('[presets]')
{
ok(
rules.length === DEFAULT_HIGHLIGHT_RULES.length,
`every preset rule compiles (${rules.length}/${DEFAULT_HIGHLIGHT_RULES.length})`
)
const ids = DEFAULT_HIGHLIGHT_RULES.map((r) => r.id)
ok(new Set(ids).size === ids.length, 'preset ids are unique')
const bad = DEFAULT_HIGHLIGHT_RULES.filter((r) => !/^#[0-9a-fA-F]{6}$/.test(r.color.fg))
ok(bad.length === 0, 'every preset has a #rrggbb foreground colour')
const statuses = ['okstate', 'warnstate', 'badstate', 'danger']
ok(
statuses.every((id) => DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === id)?.caseInsensitive === true),
'status presets match case-insensitively'
)
}
// ---- 2. success words, in any case ------------------------------------------
console.log('[success]')
{
const words = [
'SUCCESS',
'Success',
'success',
'successful',
'succeeded',
'SUCCEEDS',
'PASS',
'passed',
'Passes',
'ok',
'Ok',
'OK',
'okay',
'done',
'DONE',
'finished',
'complete',
'completed',
'good',
'Good',
'GOOD',
'fine',
'true',
'enabled',
'active',
'online',
'ready',
'healthy',
'matched',
'found',
'accepted',
'approved',
'valid',
'allowed'
]
const missed = words.filter((w) => !colored(`${w} 1`, OK_GREEN, w))
ok(missed.length === 0, `all success words highlight (missed: ${missed.join(', ') || 'none'})`)
}
// ---- 3. failure words, in any case ------------------------------------------
console.log('[failure]')
{
const words = [
'ERROR',
'error',
'Error',
'ERR',
'err',
'errno',
'NG',
'ng',
'BUG',
'bug',
'buggy',
'FAILED',
'failed',
'FAILURE',
'fatal',
'CRITICAL',
'critical',
'PANIC',
'exception',
'Traceback',
'aborted',
'crashed',
'segfault',
'denied',
'refused',
'rejected',
'invalid',
'illegal',
'mismatch',
'timeout',
'unreachable',
'unhealthy',
'conflict',
'corrupted',
'missing',
'bad',
'wrong',
'offline'
]
const missed = words.filter((w) => !colored(`${w} 1`, BAD_RED, w))
ok(missed.length === 0, `all failure words highlight (missed: ${missed.join(', ') || 'none'})`)
// negated success words belong to the failure rule as a whole phrase
for (const phrase of ['not ok', 'NOT OK', 'not done', 'not found', 'Not Passed']) {
ok(colored(`${phrase} 1`, BAD_RED, phrase), `"${phrase}" highlights as a failure`)
}
}
// ---- 4. warnings ------------------------------------------------------------
console.log('[warning]')
{
const words = ['WARN', 'warn', 'WARNING', 'Warnings', 'caution', 'notice', 'DEPRECATED', 'TODO', 'FIXME']
const missed = words.filter((w) => !colored(`${w} 1`, WARN_YELLOW, w))
ok(missed.length === 0, `all warning words highlight (missed: ${missed.join(', ') || 'none'})`)
ok(!colored('warning 1', BAD_RED, 'warning'), 'a warning is not also painted as an error')
}
// ---- 5. status symbols (checks, crosses, warning sign) ----------------------
console.log('[symbols]')
{
const glyphs = [
['\u2713', OK_GREEN], // ✓ check mark
['\u2714', OK_GREEN], // ✔ heavy check mark
['\u2705', OK_GREEN], // ✅ white heavy check mark
['\u2717', BAD_RED], // ✗ ballot x
['\u2718', BAD_RED], // ✘ heavy ballot x
['\u2716', BAD_RED], // ✖ heavy multiplication x
['\u274C', BAD_RED], // ❌ cross mark
['\u26A0', WARN_YELLOW] // ⚠ warning sign
]
const missed = glyphs.filter(([glyph, hex]) => !colored(`build ${glyph} ok`, hex, glyph))
ok(
missed.length === 0,
`every status symbol highlights (missed: ${missed.map(([g]) => g).join(' ') || 'none'})`
)
// an emoji variation selector rides along inside the coloured span
ok(
colored('build \u2705\uFE0F ok', OK_GREEN, '\u2705\uFE0F'),
'the emoji variation selector stays inside the coloured span'
)
// the two families never cross over
ok(!colored('\u2713', BAD_RED, '\u2713'), 'a check mark is never painted as a failure')
ok(!colored('\u2717', OK_GREEN, '\u2717'), 'a cross mark is never painted as a success')
}
// ---- 6. no false positives inside longer words ------------------------------
console.log('[word boundaries]')
{
const cases = [
['valid', 'invalid', OK_GREEN],
['enabled', 'disabled', OK_GREEN],
['active', 'inactive', OK_GREEN],
['healthy', 'unhealthy', OK_GREEN],
['pass', 'passwd', OK_GREEN],
['passed', 'bypassed', OK_GREEN],
['found', 'foundation', OK_GREEN],
['ok', 'okapi', OK_GREEN],
['fine', 'define', OK_GREEN]
]
for (const [inner, word, hex] of cases) {
ok(!colored(word, hex, inner), `"${word}" does not highlight the "${inner}" inside it`)
}
ok(!colored('not ok', OK_GREEN, 'ok'), 'the ok in "not ok" is never green')
ok(!colored('not ok', OK_GREEN, 'not ok'), '"not ok" is not green as a whole phrase')
}
// ---- 7. destructive commands ------------------------------------------------
console.log('[danger]')
{
const cases = [
'rm -rf /tmp/x',
'sudo rm -fr build',
'mkfs.ext4 /dev/sdb1',
'dd if=/dev/zero of=/dev/sda',
'git reset --hard HEAD~3',
'git push --force',
'drop table users'
]
const missed = cases.filter((c) => !applyHighlights(c, rules).includes(sgr(DANGER_RED)))
ok(missed.length === 0, `destructive commands are flagged (missed: ${missed.join(' | ') || 'none'})`)
// ...while ordinary output that merely contains such letters is left alone
const quiet = ['reformat the disk image', 'the alarm was halted', 'remember to remount it']
const wrong = quiet.filter((t) => applyHighlights(t, rules).includes(sgr(DANGER_RED)))
ok(
wrong.length === 0,
`non-command text is not flagged (flagged: ${wrong.join(' | ') || 'none'})`
)
}
// ---- 8. case-insensitive flag is opt-in per rule ----------------------------
console.log('[case flag]')
{
const plain = compileRules([
{ id: 'cs', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(!applyHighlights('error', plain).includes(sgr(BAD_RED)), 'a rule without the flag still respects case')
const folded = compileRules([
{ id: 'ci', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED }, caseInsensitive: true }
])
ok(applyHighlights('error', folded).includes(sgr(BAD_RED)), 'the flag makes a rule case-insensitive')
}
// ---- 9. file operations (create vs delete) ---------------------------------
console.log('[file ops]')
{
const deleted = [
'delete',
'deleted',
'Deleted',
'deleting',
'remove',
'removed',
'removal',
'rm',
'rmdir',
'unlink',
'purge',
'DESTROYED',
'truncate',
'wipe',
'revoked',
'unmounted',
'killed',
'drop',
'dropped',
'cleanup',
'moved',
'renamed',
'overwritten'
]
const missedDelete = deleted.filter((w) => !colored(`${w} 1`, DEL_ORANGE, w))
ok(
missedDelete.length === 0,
`delete/side-effect words highlight (missed: ${missedDelete.join(', ') || 'none'})`
)
const created = [
'create',
'created',
'Created',
'creating',
'creation',
'mkdir',
'touch',
'add',
'added',
'Added',
'install',
'installed',
'clone',
'cloned',
'generate',
'generated',
'init',
'initialized',
'import',
'upload',
'downloaded',
'saved',
'wrote',
'written',
'mounted'
]
const missedCreate = created.filter((w) => !colored(`${w} 1`, NEW_GREEN, w))
ok(
missedCreate.length === 0,
`create/install words highlight (missed: ${missedCreate.join(', ') || 'none'})`
)
// neighbours that merely contain the letters stay untouched
for (const [text, hex, inner] of [
['address', NEW_GREEN, 'add'],
['initial', NEW_GREEN, 'init'],
['movie', DEL_ORANGE, 'move'],
['form', DEL_ORANGE, 'rm'],
['keeper', DEL_ORANGE, 'keep']
]) {
ok(!colored(text, hex, inner), `"${text}" does not highlight the "${inner}" inside it`)
}
// silent-e stems: delete→deleting, save→saving, create→creating. These are the
// forms a naive `DELETE(?:D|S|ING)?` misses, so they get their own sweep.
const gerunds = [
['deleting', DEL_ORANGE],
['moving', DEL_ORANGE],
['renaming', DEL_ORANGE],
['truncating', DEL_ORANGE],
['erasing', DEL_ORANGE],
['purging', DEL_ORANGE],
['wiping', DEL_ORANGE],
['revoking', DEL_ORANGE],
['terminating', DEL_ORANGE],
['creating', NEW_GREEN],
['cloning', NEW_GREEN],
['saving', NEW_GREEN],
['generating', NEW_GREEN],
['initializing', NEW_GREEN],
['installing', NEW_GREEN],
['adds', NEW_GREEN],
['completing', OK_GREEN],
['succeeding', OK_GREEN],
['passing', OK_GREEN],
['finishing', OK_GREEN],
['enabling', OK_GREEN],
['failing', BAD_RED],
['aborting', BAD_RED],
['crashing', BAD_RED],
['refusing', BAD_RED],
['corrupting', BAD_RED]
]
const missedForms = gerunds.filter(([w, hex]) => !colored(`${w} 1`, hex, w))
ok(
missedForms.length === 0,
`inflected forms highlight (missed: ${missedForms.map(([w]) => w).join(', ') || 'none'})`
)
// the two families never cross over, and danger still owns destructive commands
ok(!colored('deleted 1', NEW_GREEN, 'deleted'), 'a delete word is never painted as a create')
ok(!colored('created 1', DEL_ORANGE, 'created'), 'a create word is never painted as a delete')
ok(
applyHighlights('drop table users', rules).includes(sgr(DANGER_RED)),
'danger still claims "drop table" ahead of the delete rule'
)
ok(
applyHighlights('export PATH=/usr/bin:$PATH', rules).includes(sgr('#d2a8ff')),
'export stays a shell keyword, not a file operation'
)
}
// ---- 10. value bands (percentages) -----------------------------------------
console.log('[value bands]')
{
const BAND_LIGHT_GREEN = '#7ee787'
const at = (value) => applyHighlights(`${value}% done`, rules)
const expected = [
['0', BAD_RED],
['5', BAD_RED],
['19', BAD_RED],
['19.9', BAD_RED],
['20', WARN_YELLOW],
['35', WARN_YELLOW],
['49.9', WARN_YELLOW],
['50', BAND_LIGHT_GREEN],
['79', BAND_LIGHT_GREEN],
['80', OK_GREEN],
['99', OK_GREEN],
['100', OK_GREEN]
]
const wrong = expected.filter(([value, hex]) => !at(value).includes(`${sgr(hex)}${value}%\x1b[0m`))
ok(
wrong.length === 0,
`percentages pick their band by value (wrong: ${wrong.map(([v]) => `${v}%`).join(', ') || 'none'})`
)
// a rule without bands keeps one colour whatever the number
const flat = compileRules([
{ id: 'flat', pattern: '\\d+%', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(applyHighlights('77%', flat).includes(sgr(BAD_RED)), 'a rule without bands stays flat')
// with bands: the match's own number decides, and no number falls back
const banded = compileRules([
{
id: 'banded',
pattern: '\\bDONE\\b|\\d+%',
enabled: true,
priority: 1,
color: { fg: OK_GREEN },
bands: [
{ min: 0, fg: BAD_RED },
{ min: 90, fg: OK_GREEN }
]
}
])
ok(applyHighlights('DONE', banded).includes(sgr(OK_GREEN)), 'no number in the match → the rule colour')
ok(applyHighlights('5%', banded).includes(sgr(BAD_RED)), 'a number below the second band → the first band')
ok(applyHighlights('95%', banded).includes(sgr(OK_GREEN)), 'a number above the last min → the last band')
// bands stored out of order still resolve lowest-first
const unsorted = compileRules([
{
id: 'unsorted',
pattern: '\\d+%',
enabled: true,
priority: 1,
color: { fg: OK_GREEN },
bands: [
{ min: 80, fg: OK_GREEN },
{ min: 0, fg: BAD_RED },
{ min: 50, fg: BAND_LIGHT_GREEN }
]
}
])
ok(
applyHighlights('10%', unsorted).includes(sgr(BAD_RED)) &&
applyHighlights('60%', unsorted).includes(sgr(BAND_LIGHT_GREEN)) &&
applyHighlights('90%', unsorted).includes(sgr(OK_GREEN)),
'bands are applied lowest-first regardless of stored order'
)
}
// ---- 11. log levels, exit codes, HTTP codes, durations, secrets -------------
console.log('[ops & levels]')
{
const LOG_BLUE = '#79c0ff'
const ROOT_ORANGE = '#ffa657'
const HTTP_GRAY = '#8b949e'
for (const word of ['INFO', 'DEBUG', 'TRACE', 'VERBOSE']) {
ok(colored(`${word} starting up`, LOG_BLUE, word), `"${word}" highlights as a log level`)
}
ok(!colored('for more information see docs', LOG_BLUE, 'INFO'), 'lowercase "information" is not a log level')
// NOTICE belongs to the warning rule (it is a warning level), not this one
ok(colored('NOTICE disk almost full', WARN_YELLOW, 'NOTICE'), 'NOTICE stays with the warning rule')
ok(colored('root@web01:~# ls', ROOT_ORANGE, 'root@web01'), 'a root prompt is flagged')
ok(!colored('admin@web01:~$ ls', ROOT_ORANGE, 'admin@web01'), 'a normal user prompt is not')
ok(colored('exit code 0', OK_GREEN, 'exit code 0'), 'a zero exit code is green')
ok(colored('Process exited with code 1', BAD_RED, 'exited with code 1'), 'a non-zero exit code is red')
ok(colored('exit 2', BAD_RED, 'exit 2'), 'a bare "exit 2" is red')
ok(colored('HTTP/1.1 404 Not Found', WARN_YELLOW, '404'), 'a 4xx code is yellow')
ok(
!applyHighlights('HTTP/1.1 404 Not Found', rules).includes(`${sgr(WARN_YELLOW)}1.1`),
'the HTTP version is not swept into the status-code span'
)
ok(colored('status: 500', BAD_RED, '500'), 'status: 500 is red')
ok(colored('status 201', OK_GREEN, '201'), 'status 201 is green')
ok(colored('301 Moved Permanently', '#58a6ff', '301'), 'a 3xx with its reason phrase is blue')
ok(colored('204 No Content', OK_GREEN, '204'), '204 No Content is green')
ok(colored('HTTP/2 100', HTTP_GRAY, '100'), 'a 1xx code is grey')
ok(
!applyHighlights('processed 404 items', rules).includes(sgr(BAD_RED)) &&
!applyHighlights('404', rules).includes(sgr(BAD_RED)),
'a bare 3-digit number is not treated as a status code'
)
ok(colored('20ms', OK_GREEN, '20ms'), 'a fast duration is green')
ok(colored('120ms', WARN_YELLOW, '120ms'), 'a middling duration is yellow')
ok(colored('900ms', BAD_RED, '900ms'), 'a slow duration is red')
ok(!applyHighlights('took 2s', rules).includes(sgr(BAD_RED)), 'plain seconds are left to the number rule')
ok(colored('sk-abcdefghijklmnopqrstuvwxyz012345', DANGER_RED, 'sk-abcdefghijklmnopqrstuvwxyz012345'), 'an API key is flagged')
ok(colored('AKIAIOSFODNN7EXAMPLE', DANGER_RED, 'AKIAIOSFODNN7EXAMPLE'), 'an AWS key id is flagged')
ok(
colored('-----BEGIN RSA PRIVATE KEY-----', DANGER_RED, '-----BEGIN RSA PRIVATE KEY-----'),
'a PEM private key header is flagged'
)
ok(
colored('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijk', DANGER_RED, 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijk'),
'a bearer token is flagged'
)
for (const cmd of [
'curl -fsSL https://x.sh | sh',
'wget -qO- https://x.sh | sudo bash',
'eval "$(ssh-agent)"',
'base64 -d payload.txt | sh',
'chmod +x install.sh'
]) {
ok(applyHighlights(cmd, rules).includes(sgr(DANGER_RED)), `"${cmd}" is flagged as dangerous`)
}
ok(!applyHighlights('npm run build', rules).includes(sgr(DANGER_RED)), 'an ordinary command stays unflagged')
}
// ---- 12. import / export round trip ----------------------------------------
console.log('[import / export]')
{
const exported = exportHighlightRules(DEFAULT_HIGHLIGHT_RULES)
const back = parseHighlightRules(exported)
ok(
back.error === undefined && back.rules.length === DEFAULT_HIGHLIGHT_RULES.length,
'an exported set parses back whole'
)
ok(
back.rules.every((r) => r.builtin !== true),
'imported rules are never marked builtin (they are the user’s now)'
)
ok(
new Set(back.rules.map((r) => r.id)).size === back.rules.length,
'imported rules get unique ids'
)
ok(
back.rules.some((r) => r.caseInsensitive === true) && back.rules.some((r) => Array.isArray(r.bands)),
'the case flag and value bands survive the round trip'
)
ok(parseHighlightRules('not json at all').error === 'not-json', 'garbage is refused as JSON')
ok(
parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind',
'a foreign envelope is refused'
)
ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused')
ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted')
const partial = parseHighlightRules(
JSON.stringify({
rules: [
{ pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } },
{ pattern: '[' },
{ nope: true }
]
})
)
ok(
partial.rules.length === 1 &&
partial.rules[0].priority === 100 &&
partial.rules[0].color.fg === '#3fb950',
'a repairable rule is imported, its priority clamped and colour defaulted'
)
ok(partial.warnings.length === 2, 'unusable entries are counted as skipped')
const mine = { id: 'mine', pattern: '\\bX\\b', enabled: true, priority: 1, color: { fg: '#ffffff' } }
const appended = mergeRules([mine], back.rules, 'append')
ok(appended.length === DEFAULT_HIGHLIGHT_RULES.length + 1, 'append keeps the existing rules')
ok(new Set(appended.map((r) => r.id)).size === appended.length, 'append never leaves duplicate ids')
ok(mergeRules([mine], back.rules, 'replace').length === DEFAULT_HIGHLIGHT_RULES.length, 'replace drops the old set')
}
// ---- 13. editor preview spans ----------------------------------------------
console.log('[preview]')
{
const spans = previewSpans('SUCCESS 85% ERROR', rules)
ok(spans.map((s) => s.text).join('') === 'SUCCESS 85% ERROR', 'preview spans reassemble the input exactly')
ok(spans.some((s) => s.text === 'SUCCESS' && s.fg === OK_GREEN), 'a preview span carries its colour')
ok(spans.some((s) => s.text === 'ERROR' && s.fg === BAD_RED), 'a failure span carries its colour too')
ok(
spans.some((s) => s.text === '85%' && s.fg === OK_GREEN),
'the preview honours value bands (85% is in the green band)'
)
ok(
previewSpans('\x1b[31mERROR\x1b[0m', rules).map((s) => s.text).join('') === 'ERROR',
'escape sequences in the sample are stripped before previewing'
)
ok(previewSpans('nothing here', []).length === 1, 'no rules → the sample stays one plain span')
}
// ---- 14. the basic-mode set -------------------------------------------------
console.log('[basic mode]')
{
const basic = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.basic === true).map((r) => r.id)
ok(basic.length === 5, `the basic set stays small (${basic.join(', ') || 'empty'})`)
for (const id of ['danger', 'secret', 'okstate', 'warnstate', 'badstate']) {
ok(basic.includes(id), `"${id}" survives basic mode`)
}
ok(
!basic.includes('numbers') && !basic.includes('url') && !basic.includes('path'),
'the noisy rules are not part of basic mode'
)
ok(
DEFAULT_HIGHLIGHT_RULES.every((r) => r.basic === undefined || r.basic === true),
'basic is a flag, never explicitly false'
)
}
// ---- 15. preset categories -------------------------------------------------
console.log('[categories]')
{
const missing = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.category === undefined).map((r) => r.id)
ok(missing.length === 0, `every preset carries a category (missing: ${missing.join(', ') || 'none'})`)
const known = new Set(['safety', 'status', 'file', 'net', 'text', 'metric'])
ok(
DEFAULT_HIGHLIGHT_RULES.every((r) => known.has(r.category)),
'every category is one of the known values'
)
const safety = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.category === 'safety').map((r) => r.id)
ok(safety.length >= 2 && safety.includes('danger') && safety.includes('secret'), 'safety groups danger + secrets')
const basic = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.basic === true)
ok(
basic.every((r) => r.category === 'safety' || r.category === 'status'),
'basic mode only ever contains safety/status rules'
)
}
// ---- 16. optional stats sink ----------------------------------------------
console.log('[stats]')
{
const sink = new Map()
applyHighlights('SUCCESS 85% ERROR 85%', rules, sink)
ok(sink.get('okstate')?.hits === 1, `the sink counts hits per rule (okstate=${sink.get('okstate')?.hits})`)
ok(sink.get('percent')?.hits === 2, 'the same rule counts every one of its matches')
ok(typeof sink.get('okstate')?.ms === 'number' && sink.get('okstate').ms >= 0, 'the sink records a duration')
ok(
applyHighlights('SUCCESS', rules) === applyHighlights('SUCCESS', rules, undefined),
'passing no sink produces byte-identical output'
)
const untouched = new Map()
applyHighlights('nothing to see here', rules, untouched)
ok(
[...untouched.values()].every((stat) => stat.hits === 0),
'rules that match nothing report zero hits'
)
}
// ---- 17. theme colour mapping ---------------------------------------------
console.log('[theme colours]')
{
const theme = {
colors: {
red: '#ff0000',
brightRed: '#ff8888',
green: '#00ff00',
brightGreen: '#88ff88',
yellow: '#ffff00',
brightYellow: '#ffff88',
blue: '#0000ff',
brightBlue: '#8888ff',
cyan: '#00ffff',
brightCyan: '#88ffff',
magenta: '#ff00ff',
brightMagenta: '#ff88ff'
}
}
ok(paletteMatch('#3fb950')?.name === 'green' && paletteMatch('#3fb950').bright === false, 'the success green buckets to green')
ok(paletteMatch('#7ee787')?.name === 'green' && paletteMatch('#7ee787').bright === true, 'the light green takes the bright entry')
ok(paletteMatch('#f85149')?.name === 'red', 'the error red buckets to red')
ok(paletteMatch('#e3b341')?.name === 'yellow', 'the warning yellow buckets to yellow')
ok(paletteMatch('#d2a8ff')?.name === 'magenta', 'shell keywords bucket to magenta')
ok(paletteMatch('#58a6ff')?.name === 'blue', 'links bucket to blue')
ok(paletteMatch('#8b949e') === null, 'the neutral grey keeps its own colour')
ok(themeColorFor('#3fb950', theme) === '#00ff00', 'green resolves to the theme green')
ok(themeColorFor('#7ee787', theme) === '#88ff88', 'light green resolves to the bright green')
ok(themeColorFor('#8b949e', theme) === '#8b949e', 'a neutral colour is returned untouched')
ok(themeColorFor('#3fb950', { colors: {} }) === '#3fb950', 'a theme missing that entry falls back to the rule colour')
const mapped = applyThemeColors(DEFAULT_HIGHLIGHT_RULES, theme)
const percent = mapped.find((r) => r.id === 'percent')
ok(
percent.bands.length === 4 && percent.bands[2].fg === '#88ff88' && percent.bands[3].fg === '#00ff00',
'value bands are mapped as well (light green vs green stays distinguishable)'
)
ok(
mapped.every((r) => /^#[0-9a-fA-F]{6}$/.test(r.color.fg)),
'every mapped colour is still a hex colour'
)
ok(
DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === 'okstate').color.fg === '#3fb950',
'mapping never mutates the preset set itself'
)
}
// ---- 18. per-host profile selection ---------------------------------------
console.log('[profiles]')
{
const presets = DEFAULT_HIGHLIGHT_RULES
const profiles = [
{ id: 'p1', name: 'prod', ruleIds: ['okstate', 'badstate'] },
{ id: 'p2', name: 'empty', ruleIds: [] }
]
ok(rulesForProfile(presets, undefined, profiles).length === presets.length, 'no binding → every rule')
ok(rulesForProfile(presets, 'missing', profiles).length === presets.length, 'a dangling binding → every rule')
ok(rulesForProfile(presets, 'p2', profiles).length === presets.length, 'an empty profile → every rule')
const subset = rulesForProfile(presets, 'p1', profiles)
ok(
subset.length === 2 && subset.every((r) => ['okstate', 'badstate'].includes(r.id)),
'a bound profile narrows the session to its own rules'
)
const known = new Set(presets.map((r) => r.id))
const cleaned = sanitizeProfiles(
[
{ id: 'a', name: 'ok', ruleIds: ['okstate', 'ghost', 'okstate'] },
{ id: 'a', name: 'dupe', ruleIds: [] },
{ name: 'no id' },
'nope'
],
known,
() => undefined
)
ok(
cleaned.length === 1 && cleaned[0].ruleIds.length === 1,
'profiles are cleaned: ghost rule ids, duplicate ids and junk entries dropped'
)
ok(sanitizeProfiles('nope', known, () => undefined).length === 0, 'a non-array profile list is ignored')
ok(
excludedByProfile(presets, profiles[0]).length === presets.length - 2,
'excludedByProfile reports what a profile leaves out'
)
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+2 -6
View File
@@ -3,13 +3,9 @@
// deliberately nasty chunk splits, and requires byte-identical output vs the
// single-chunk reference.
import { compileRules, applyHighlights, HighlightStream } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const rules = compileRules([
{ id: 'okstate', pattern: '\\b(SUCCESS|PASS|OK|DONE)\\b', enabled: true, priority: 5, color: { fg: '#3fb950' } },
{ id: 'badstate', pattern: '\\b(ERROR|FAILED)\\b', enabled: true, priority: 6, color: { fg: '#f85149' } },
{ id: 'numbers', pattern: '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', enabled: true, priority: 25, color: { fg: '#f2cc60' } },
{ id: 'url', pattern: 'https?://[^\\s]+', enabled: true, priority: 30, color: { fg: '#58a6ff' } }
])
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints
const banner =
+204 -1
View File
@@ -6,6 +6,8 @@
* to 'tray', which made the "ask every time" option dead in the UI)
* - a rule with a repairable shape (priority "5", enabled 1, missing fg
* colour) is corrected rather than dropped
* - preset rules are carried onto the current pattern set by an upgrade,
* without disturbing the user's own edits, deletions or colour choices
* - an unusable entry is skipped and written to settings-warnings.log
*
* Build + run:
@@ -93,7 +95,208 @@ console.log('[highlight rules]')
ok(out.length === 0, 'an explicitly empty rule list stays empty')
}
// ---- 3. warnings land in a log file ----------------------------------------
// ---- 3. value bands ---------------------------------------------------------
console.log('[value bands]')
{
const out = (
await roundTrip({
highlightRules: [
rule({
bands: [
{ min: 80, fg: '#00ff00' },
{ min: 0, fg: '#ff0000' }
]
})
]
})
).highlightRules
ok(
out.length === 1 && out[0].bands?.length === 2 && out[0].bands[0].min === 0,
'bands survive the round trip, sorted ascending'
)
}
{
const out = (
await roundTrip({
highlightRules: [
rule({
bands: [
{ min: 'x', fg: '#ff0000' },
{ min: 20, fg: 'not-a-colour' },
{ min: 50, fg: '#00ff00' }
]
})
]
})
).highlightRules
ok(
out.length === 1 && out[0].bands?.length === 1 && out[0].bands[0].min === 50,
'unusable bands are dropped, the usable one is kept'
)
}
{
const out = (await roundTrip({ highlightRules: [rule({ bands: 'nope' })] })).highlightRules
ok(out.length === 1 && out[0].bands === undefined, 'a non-array bands value is ignored')
}
{
for (const mode of ['all', 'basic', 'off']) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, highlightMode: mode }
})
ok(back.terminal.highlightMode === mode, `highlight mode '${mode}' survives the round trip`)
}
const bad = await roundTrip({
terminal: { ...store.loadSettings().terminal, highlightMode: 'nonsense' }
})
ok(bad.terminal.highlightMode === 'nonsense', 'an unknown mode is stored as-is (readers normalise it)')
}
{
const kept = (await roundTrip({ highlightRules: [rule({ basic: true })] })).highlightRules
ok(kept[0].basic === true, 'a basic rule keeps its flag')
const dropped = (await roundTrip({ highlightRules: [rule({ basic: 'yes' })] })).highlightRules
ok(dropped.length === 1 && dropped[0].basic === undefined, 'a non-boolean basic flag is dropped')
}
{
for (const on of [true, false]) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, highlightStats: on }
})
ok(back.terminal.highlightStats === on, `the stats switch '${on}' survives the round trip`)
}
}
{
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
await roundTrip({ highlightRules: rules })
const back = (
await roundTrip({
highlightProfiles: [
{ id: 'p1', name: 'production', ruleIds: ['keep-1', 'keep-2'] },
{ id: 'p2', name: 'empty', ruleIds: [] }
]
})
).highlightProfiles
ok(
back.length === 2 && back[0].name === 'production' && back[0].ruleIds.length === 2,
'profiles survive the round trip with their rule selections'
)
const pruned = (
await roundTrip({
highlightProfiles: [{ id: 'p3', name: 'x', ruleIds: ['keep-1', 'gone'] }]
})
).highlightProfiles
ok(
pruned.length === 1 && pruned[0].ruleIds.length === 1,
'a profile drops rule ids that no longer exist'
)
const bad = (await roundTrip({ highlightProfiles: [{ name: 'no id' }, 'nope'] })).highlightProfiles
ok(bad.length === 0, 'malformed profiles are skipped')
}
{
const kept = (await roundTrip({ highlightRules: [rule({ category: 'status' })] })).highlightRules
ok(kept[0].category === 'status', 'a known category is kept')
const dropped = (await roundTrip({ highlightRules: [rule({ category: 'nonsense' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].category === undefined,
'an unknown category is dropped rather than stored'
)
}
// ---- 4. preset refresh on an upgrading install ------------------------------
console.log('[preset refresh]')
{
// The rule set as v1.0.15 shipped it, i.e. what an upgrading user has on disk.
const V1 = {
perm: '^[-dlbcps][rwxstST-]{9}',
path: '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)',
shellkw:
'\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b',
okstate: '\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b',
badstate: '\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b',
quoted: '"[^"\\n]*"|\'[^\'\\n]*\'',
envvar: '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?',
ipv4: '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b',
datetime: '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}\\b',
numbers: '\\b\\d+(?:\\.\\d+)?(?:%)?\\b',
url: 'https?://[^\\s]+'
}
const v1Rule = (id, over = {}) => ({
id,
pattern: V1[id],
enabled: true,
priority: 10,
color: { fg: '#123456' },
builtin: true,
...over
})
const v1Set = () => Object.keys(V1).map((id) => v1Rule(id))
const upgraded = (await roundTrip({ highlightRules: v1Set() })).highlightRules
const okstate = upgraded.find((r) => r.id === 'okstate')
ok(
typeof okstate?.pattern === 'string' && okstate.pattern.includes('SUCCEED'),
'an unedited built-in pattern is refreshed to the current preset'
)
ok(okstate?.caseInsensitive === true, 'the refresh brings the case-insensitive flag along')
ok(
okstate?.priority === 10 && okstate?.color.fg === '#123456' && okstate?.enabled === true,
'a refreshed rule keeps the priority, colour and switch the user left it at'
)
const addedSince = [
'warnstate',
'danger',
'delop',
'createop',
'percent',
'secret',
'loglevel',
'rootat',
'exitcode',
'http',
'latency'
]
const stillMissing = addedSince.filter((id) => !upgraded.some((r) => r.id === id))
ok(
stillMissing.length === 0,
`preset rules added since an older install are appended (missing: ${stillMissing.join(', ') || 'none'})`
)
ok(
upgraded.filter((r) => r.builtin !== true).length === 0,
'no stray rule is invented while refreshing'
)
// A rule the user rewrote by hand must survive an upgrade untouched.
const custom = v1Set().map((r) => (r.id === 'badstate' ? { ...r, pattern: '\\bMY_FAILURE\\b' } : r))
const afterCustom = (await roundTrip({ highlightRules: custom })).highlightRules
ok(
afterCustom.find((r) => r.id === 'badstate')?.pattern === '\\bMY_FAILURE\\b',
'a hand-edited built-in rule is left alone'
)
// ...and so must a rule the user deleted: it must not come back.
const pruned = v1Set().filter((r) => r.id !== 'numbers')
const afterPrune = (await roundTrip({ highlightRules: pruned })).highlightRules
ok(!afterPrune.some((r) => r.id === 'numbers'), 'a deleted built-in rule is not resurrected')
ok(
!afterPrune.some((r) => r.id === 'warnstate'),
'no untouched preset is added either, so a deletion stays a deletion'
)
// Deleting one of the *newly added* presets sticks too (the append guard must
// not treat the upgraded set as a pristine old one).
const afterUpgradePrune = (
await roundTrip({ highlightRules: upgraded.filter((r) => r.id !== 'warnstate') })
).highlightRules
ok(
!afterUpgradePrune.some((r) => r.id === 'warnstate'),
'a newly added preset the user deleted is not re-added'
)
// Loading the upgraded set again changes nothing further.
const again = (await roundTrip({ highlightRules: upgraded })).highlightRules
ok(JSON.stringify(again) === JSON.stringify(upgraded), 'the refresh is idempotent')
}
// ---- 4. warnings land in a log file ----------------------------------------
console.log('[warnings log]')
{
writeFileSync(