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:
1 parent
41b1cb1b25
commit
e08e1cfec4
39 files changed
+3164
-89
No files matched your search
@@ -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/
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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
@@ -68,7 +68,7 @@
|
||||
- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴)
|
||||
- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成)
|
||||
- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示
|
||||
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 11 条预设 + 设置页管理,见 M2.5)
|
||||
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 22 条预设 + 设置页管理,见 M2.5;状态类预设不分大小写、覆盖 ✓/✗ 符号,删除/新建操作单独着色,百分比/退出码/HTTP 状态码/耗时按数值分级取色,含密钥泄露提醒与危险管道,旧安装的预设规则在加载时按「未被用户改动过」升级)
|
||||
- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅
|
||||
|
||||
## M6 — 多终端协同 ✅
|
||||
|
||||
+5
-4
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 アドレス',
|
||||
|
||||
@@ -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': 'デフォルト',
|
||||
|
||||
@@ -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 地址',
|
||||
|
||||
@@ -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': '默认',
|
||||
|
||||
@@ -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 位址',
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user