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

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

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

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

No files matched your search

+126 -3
View File
@@ -5,6 +5,7 @@ import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
isHighlightCategory,
type AppSettings,
type HighlightRule,
type SystemSettings,
@@ -12,6 +13,7 @@ import {
} from '../shared/settings'
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { sanitizeProfiles } from '../shared/highlightProfiles'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
@@ -80,6 +82,39 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
warnings.push(`highlightRules[${index}].color repaired → ${DEFAULT_RULE_COLOR}`)
}
if (rule.caseInsensitive !== undefined && typeof rule.caseInsensitive !== 'boolean') {
warnings.push(`highlightRules[${index}].caseInsensitive ignored (not a boolean)`)
}
if (rule.basic !== undefined && typeof rule.basic !== 'boolean') {
warnings.push(`highlightRules[${index}].basic ignored (not a boolean)`)
}
if (rule.category !== undefined && !isHighlightCategory(rule.category)) {
warnings.push(`highlightRules[${index}].category ignored (unknown value)`)
}
// Value bands are repaired one entry at a time: a band without a usable min or
// colour is dropped, and what survives is sorted so the engine can walk it.
let bands: { min: number; fg: string }[] | undefined
if (rule.bands !== undefined) {
if (Array.isArray(rule.bands)) {
const kept: { min: number; fg: string }[] = []
rule.bands.forEach((entry, bandIndex) => {
const band = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>) : null
const min = typeof band?.min === 'number' ? band.min : Number(band?.min)
const fg = typeof band?.fg === 'string' ? band.fg.trim() : ''
if (band === null || !Number.isFinite(min) || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) {
warnings.push(`highlightRules[${index}].bands[${bandIndex}]: unusable — skipped`)
return
}
kept.push({ min, fg })
})
kept.sort((a, b) => a.min - b.min)
if (kept.length > 0) bands = kept
} else {
warnings.push(`highlightRules[${index}].bands ignored (not an array)`)
}
}
return {
...rule,
id: rule.id,
@@ -87,10 +122,82 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
enabled,
priority,
color,
bands,
// `undefined` rather than `false` so the flag stays absent unless it is on.
caseInsensitive: rule.caseInsensitive === true ? true : undefined,
basic: rule.basic === true ? true : undefined,
category: isHighlightCategory(rule.category) ? rule.category : undefined,
note: typeof rule.note === 'string' ? rule.note : undefined
} as HighlightRule
}
/**
* Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule
* whose pattern is still one of these was never edited by the user, so the
* load-time refresh below may swap in the current preset for it.
*/
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
}
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
const LEGACY_BUILTIN_IDS = [
'perm',
'path',
'shellkw',
'okstate',
'badstate',
'quoted',
'envvar',
'ipv4',
'datetime',
'numbers',
'url'
]
/**
* Carry an older install's preset rules onto the current ones.
*
* An upgrade must not throw the user's own work away, so both steps are guarded:
* - a built-in rule still holding a shipped pattern is refreshed in place; the
* pattern and case flag come from the preset, while id, colour, priority,
* note and the enabled switch stay exactly as the user left them;
* - preset rules that did not exist yet are appended only while the stored set
* is still exactly the older preset set (nothing deleted, nothing added) —
* the moment the list is the user's own, a rule they removed stays removed.
* Both steps are idempotent: a refreshed pattern no longer matches a legacy one,
* and an upgraded set never matches the older preset id list again.
*/
function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): HighlightRule[] {
const presets = new Map(DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, rule]))
let changed = false
const refreshed = rules.map((rule) => {
const legacy = LEGACY_BUILTIN_PATTERNS[rule.id]
const preset = presets.get(rule.id)
if (!legacy || preset === undefined || rule.builtin !== true || !legacy.includes(rule.pattern)) {
return rule
}
changed = true
warnings.push(`highlightRules.${rule.id}: preset pattern updated`)
return { ...rule, pattern: preset.pattern, caseInsensitive: preset.caseInsensitive }
})
// Presets added since are appended only while the stored set is *exactly* the
// older preset set. Anything else — a rule deleted, one added by hand — means
// the set is the user's, and a preset they threw away must not come back on
// every load.
const present = refreshed.map((rule) => rule.id)
const pristine =
present.length === LEGACY_BUILTIN_IDS.length &&
LEGACY_BUILTIN_IDS.every((id) => present.includes(id))
if (!pristine) return changed ? refreshed : rules
const missing = DEFAULT_HIGHLIGHT_RULES.filter((rule) => !present.includes(rule.id))
if (missing.length === 0) return changed ? refreshed : rules
for (const rule of missing) warnings.push(`highlightRules.${rule.id}: new built-in rule added`)
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
}
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
// An explicit empty array is a valid choice ("no highlighting"); only
// malformed data falls back to the built-in rules. Returning the defaults for
@@ -101,7 +208,7 @@ function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
const rule = coerceRule(entry, index, warnings)
if (rule) rules.push(rule)
})
return rules
return refreshBuiltinRules(rules, warnings)
}
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
@@ -165,9 +272,15 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
let highlightProfiles: unknown = []
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
const packageSettings = raw as {
terminal?: unknown
customThemes?: unknown
highlightRules?: unknown
highlightProfiles?: unknown
}
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
const merged: Record<string, unknown> = { ...terminal }
@@ -187,6 +300,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
if (packageSettings.highlightRules !== undefined) {
highlightRules = packageSettings.highlightRules
}
if (packageSettings.highlightProfiles !== undefined) {
highlightProfiles = packageSettings.highlightProfiles
}
}
let system: unknown = { ...DEFAULT_SYSTEM }
@@ -217,12 +333,18 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
}
const themes = sanitizeThemes(customThemes, errors)
const rules = sanitizeRules(highlightRules, errors)
return {
settings: {
terminal,
customThemes: themes,
highlightRules: sanitizeRules(highlightRules, errors),
highlightRules: rules,
highlightProfiles: sanitizeProfiles(
highlightProfiles,
new Set(rules.map((rule) => rule.id)),
(message) => errors.push(message)
),
system: system as SystemSettings
},
errors
@@ -296,6 +418,7 @@ export function loadSettings(): AppSettings {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM }
}
}