Files
OpenTerminal/src/shared/highlightIO.ts
T
Bill e08e1cfec4 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
2026-09-23 11:14:31 +08:00

148 lines
5.0 KiB
TypeScript

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]
}