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

+6 -1
View File
@@ -57,6 +57,10 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
? rawGroup.trim()
: undefined
const rawProfile = values.highlightProfileId
const highlightProfileId =
typeof rawProfile === 'string' && rawProfile.trim() !== '' ? rawProfile.trim() : undefined
const input: SshConnectionInput = {
id: ctx.conn?.id,
name: String(values.name ?? '').trim(),
@@ -67,7 +71,8 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
askPasswordAtConnect,
askPassphraseAtConnect,
keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30,
group
group,
highlightProfileId
}
const secret = (key: SecretKind): string | undefined => {
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections'
import { getLanguage, t } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { AuthFieldsRenderer } from './Fields'
import { buildInput, num, hasSavedKind, type SecretKind } from './Common'
@@ -55,6 +56,9 @@ export function ConnectionEditDialog({
})
}, [open])
// Named highlight-rule subsets defined in Settings, for the profile Select.
const highlightProfiles = useSettingsStore((s) => s.settings.highlightProfiles)
// Populate / clear the form for each open + target change.
useEffect(() => {
if (!open) return
@@ -76,7 +80,8 @@ export function ConnectionEditDialog({
askPasswordAtConnect: editing?.askPasswordAtConnect,
askPassphraseAtConnect: editing?.askPassphraseAtConnect,
keyPath: editing?.keyPath,
keepaliveIntervalSec: editing?.keepaliveIntervalSec
keepaliveIntervalSec: editing?.keepaliveIntervalSec,
highlightProfileId: editing?.highlightProfileId
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editKey, form])
@@ -214,6 +219,14 @@ export function ConnectionEditDialog({
>
<InputNumber className="connections-keepalive-input" min={0} placeholder="30" />
</Form.Item>
<Form.Item name="highlightProfileId" label={t('ssh.dialog.highlightProfile')}>
<Select
allowClear
placeholder={t('ssh.dialog.highlightProfileDefault')}
options={highlightProfiles.map((p) => ({ value: p.id, label: p.name }))}
/>
</Form.Item>
</Form>
</Modal>
)
+1
View File
@@ -84,6 +84,7 @@ if (typeof window !== 'undefined' && !window.api) {
askPassphraseAtConnect: input.askPassphraseAtConnect,
keyPath: input.keyPath,
keepaliveIntervalSec: input.keepaliveIntervalSec,
highlightProfileId: input.highlightProfileId,
createdAt: Date.now(),
savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false }
}),
@@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
import { useSettingsStore } from './store'
interface ImportResult {
error?: ImportError
warnings: string[]
/** rules that made it in; 0 means nothing was imported */
count: number
}
/**
* Import / export of the rule set as JSON. The renderer can do all of this on
* its own (clipboard, FileReader, a download link), so no main-process dialog or
* IPC is involved — which also keeps the feature testable outside Electron.
*/
export function HighlightImportExport({
open,
onClose
}: {
open: boolean
onClose: () => void
}): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const [draft, setDraft] = useState('')
const [mode, setMode] = useState<'replace' | 'append'>('append')
const [copied, setCopied] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
const exported = useMemo(() => exportHighlightRules(highlightRules), [highlightRules])
// start clean each time the dialog opens
useEffect(() => {
if (!open) return
setDraft('')
setResult(null)
setCopied(false)
}, [open])
const handleCopy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(exported)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard can be blocked; the textarea stays selectable by hand
}
}
const handleDownload = (): void => {
const url = URL.createObjectURL(new Blob([exported], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = 'openterminal-highlight-rules.json'
link.click()
URL.revokeObjectURL(url)
}
const handleFile = (file: File): void => {
const reader = new FileReader()
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
reader.readAsText(file)
}
const errorText = (error: ImportError): string => {
if (error === 'not-json') return t('settings.highlight.importBadJson')
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
return t('settings.highlight.importEmpty')
}
const handleImport = async (): Promise<void> => {
const parsed = parseHighlightRules(draft)
if (parsed.error !== undefined) {
setResult({ error: parsed.error, warnings: [], count: 0 })
return
}
if (parsed.rules.length === 0) {
setResult({ warnings: parsed.warnings, count: 0 })
return
}
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
}
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnHidden
title={t('settings.highlight.importExportTitle')}
>
<div className="hl-io">
<div className="hl-io-section">
<div className="hl-io-label">{t('settings.highlight.exportSection')}</div>
<Input.TextArea
value={exported}
readOnly
autoSize={{ minRows: 3, maxRows: 6 }}
className="hl-io-json"
/>
<Space>
<Button size="small" onClick={() => void handleCopy()}>
{copied ? t('settings.highlight.copied') : t('settings.highlight.copy')}
</Button>
<Button size="small" onClick={handleDownload}>
{t('settings.highlight.download')}
</Button>
</Space>
</div>
<div className="hl-io-section">
<div className="hl-io-label">{t('settings.highlight.importSection')}</div>
<Input.TextArea
value={draft}
onChange={(e) => setDraft(e.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
className="hl-io-json"
/>
<Space wrap>
<label className="hl-io-file">
<input
type="file"
accept=".json,application/json"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}}
/>
<Button size="small">{t('settings.highlight.chooseFile')}</Button>
</label>
<span className="hl-editor-sub-label">{t('settings.highlight.importMode')}</span>
<Radio.Group
size="small"
value={mode}
onChange={(e) => setMode(e.target.value as 'replace' | 'append')}
>
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
</Radio.Group>
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
</Space>
{result && (
<Alert
type={result.error !== undefined || result.count === 0 ? 'error' : 'success'}
showIcon
message={
result.error !== undefined
? errorText(result.error)
: result.count === 0
? t('settings.highlight.importEmpty')
: t('settings.highlight.imported', { n: result.count })
}
description={
result.warnings.length > 0
? t('settings.highlight.importSkipped', {
n: result.warnings.length,
list: result.warnings.join('; ')
})
: undefined
}
/>
)}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import type { HighlightProfile } from '@shared/settings'
import { ruleLabel } from './ruleLabel'
import { useSettingsStore } from './store'
/**
* Named subsets of the highlight rules ("profiles"), plus the switch that makes
* them apply. A connection bound to a profile runs only that subset, which is how
* a production host gets a quieter set than a local shell. Nothing here affects a
* session until `highlightPerHost` is on and the connection is bound.
*/
export function HighlightProfiles(): React.JSX.Element {
const profiles = useSettingsStore((s) => s.settings.highlightProfiles)
const rules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightProfiles = useSettingsStore((s) => s.setHighlightProfiles)
const perHost = useSettingsStore((s) => s.settings.terminal.highlightPerHost)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
/** null = dialog closed; '' = creating; otherwise the profile being edited. */
const [editingId, setEditingId] = useState<string | null>(null)
const [name, setName] = useState('')
const [ruleIds, setRuleIds] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const editing = profiles.find((profile) => profile.id === editingId)
// seed the dialog each time it opens
useEffect(() => {
if (editingId === null) return
setName(editing?.name ?? '')
setRuleIds(editing?.ruleIds ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingId])
const handleSave = async (): Promise<void> => {
if (name.trim() === '') return
setSaving(true)
try {
const next = editing
? profiles.map((profile) =>
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
)
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
await setHighlightProfiles(next)
setEditingId(null)
} finally {
setSaving(false)
}
}
const columns: ColumnsType<HighlightProfile> = [
{
title: t('settings.highlight.profileName'),
dataIndex: 'name',
ellipsis: true
},
{
title: t('settings.highlight.profileRules'),
dataIndex: 'ruleIds',
width: 120,
align: 'center',
render: (ids: string[]) => (
<span className="hl-cell-note">
{ids.length === 0 ? t('settings.highlight.modeAll') : ids.length}
</span>
)
},
{
title: t('settings.highlight.actions'),
key: 'action',
width: 120,
align: 'center',
render: (_value, record) => (
<div className="hl-actions">
<Button size="small" onClick={() => setEditingId(record.id)}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('settings.highlight.profileDeleteTitle')}
description={t('settings.deleteConfirmDesc')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
>
<Button size="small" danger>
{t('common.delete')}
</Button>
</Popconfirm>
</div>
)
}
]
return (
<div className="hl-profiles">
<div className="hl-profiles-head">
<Tooltip title={t('settings.highlight.perHostHint')}>
<span className="hl-master">
<Switch
size="small"
checked={perHost}
onChange={(checked) => void updateTerminal({ highlightPerHost: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.perHost')}</span>
</span>
</Tooltip>
<Button size="small" onClick={() => setEditingId('')}>
{t('settings.highlight.profileNew')}
</Button>
</div>
<span className="hl-editor-hint">{t('settings.highlight.profilesHint')}</span>
<Table
size="small"
rowKey="id"
columns={columns}
dataSource={profiles}
pagination={false}
locale={{ emptyText: t('settings.highlight.profileEmpty') }}
/>
<Modal
open={editingId !== null}
onCancel={() => setEditingId(null)}
onOk={() => void handleSave()}
okText={t('common.save')}
cancelText={t('common.cancel')}
confirmLoading={saving}
destroyOnHidden
width={560}
title={editing ? t('settings.highlight.profileEdit') : t('settings.highlight.profileNew')}
okButtonProps={{ disabled: name.trim() === '' }}
>
<div className="hl-editor">
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.profileName')}</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('settings.highlight.profileNamePlaceholder')}
maxLength={40}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.profileRules')}</span>
<div className="hl-editor-control">
<span className="hl-editor-hint">{t('settings.highlight.profileRulesHint')}</span>
<Checkbox.Group
className="hl-profile-rules"
value={ruleIds}
onChange={(values) => setRuleIds(values as string[])}
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
/>
</div>
</div>
</div>
</Modal>
</div>
)
}
+359 -34
View File
@@ -6,15 +6,33 @@ import {
InputNumber,
Modal,
Popconfirm,
Segmented,
Select,
Switch,
Table,
Tooltip
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings'
import {
DEFAULT_HIGHLIGHT_RULES,
HIGHLIGHT_CATEGORIES,
highlightModeOf,
type HighlightCategory,
type HighlightMode
} from '@shared/settings'
import type { HighlightRule } from '@shared/settings'
import { useSettingsStore } from './store'
import { useResolvedTheme, useSettingsStore } from './store'
import { HighlightImportExport } from './HighlightImportExport'
import { HighlightProfiles } from './HighlightProfiles'
import { ruleNote } from './ruleLabel'
import { compileRules, previewSpans } from '../terminal/highlightEngine'
import {
getHighlightStats,
resetHighlightStats,
subscribeHighlightStats
} from '../terminal/highlightStats'
import { applyThemeColors } from '../theme/highlightColors'
import './highlight.css'
const ORDERED_SORT: 'ascend' = 'ascend'
@@ -22,18 +40,35 @@ const ORDERED_SORT: 'ascend' = 'ascend'
/** preset color swatches for the highlighting editor. */
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
/** Sample line for the editor preview — one line that exercises several presets. */
const SAMPLE_TEXT = 'SUCCESS 42 passed 85% HTTP/1.1 404 exit code 1 deleted 3 files 120ms rm -rf build'
/**
* Longest slice of the test text that gets previewed. A 200 KB paste would
* produce thousands of spans, and React re-renders every one of them per
* keystroke — measured at ~12 ms of highlighting plus a janky paint.
*/
const PREVIEW_LIMIT = 2000
/** clone the builtin preset set to prevent reference pollution by mutations. */
const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[]
/**
* Built-in rule notes are stored as zh-CN presets; the dictionaries carry a
* `settings.highlight.builtin.<id>` entry per built-in id. `t()` returns the key
* itself when it is missing, so fall back to the stored note in that case.
*/
function ruleNote(rule: HighlightRule): string | undefined {
const key = `settings.highlight.builtin.${rule.id}`
const translated = t(key)
return translated === key ? rule.note : translated
/** Compact duration for the stats column: µs below a millisecond, then ms. */
function formatMs(ms: number): string {
if (ms <= 0) return '0'
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms.toFixed(1)}ms`
}
/** Localised label for a rule's category, with a fallback for uncategorised rules. */
function categoryLabel(category: HighlightCategory | undefined): string {
return t(`settings.highlight.category.${category ?? 'none'}`)
}
/** Sort rank for the grouped view: known categories in fixed order, none last. */
function categoryRank(rule: HighlightRule): number {
if (rule.category === undefined) return HIGHLIGHT_CATEGORIES.length
const index = HIGHLIGHT_CATEGORIES.indexOf(rule.category)
return index < 0 ? HIGHLIGHT_CATEGORIES.length : index
}
function tryCompile(pattern: string): { ok: boolean; message?: string } {
@@ -52,6 +87,9 @@ interface RuleDraft {
priority: number
fg: string
bg?: string
bands?: { min: number; fg: string }[]
category?: HighlightCategory
caseInsensitive: boolean
note?: string
enabled: boolean
}
@@ -65,12 +103,20 @@ interface EditorState {
export function HighlightTab(): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const highlightMode = useSettingsStore((s) => highlightModeOf(s.settings.terminal.highlightMode))
const groupByCategory = useSettingsStore((s) => s.settings.terminal.highlightGroupByCategory)
const statsOn = useSettingsStore((s) => s.settings.terminal.highlightStats)
const themeColorsOn = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
const [editor, setEditor] = useState<EditorState>({ open: false })
const [ioOpen, setIoOpen] = useState(false)
const sorted = useMemo(
() => [...highlightRules].sort((a, b) => a.priority - b.priority),
[highlightRules]
)
const sorted = useMemo(() => {
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
// Grouped view: cluster by category, keeping priority order inside a group
// (Array.prototype.sort is stable, so the priority sort above survives).
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
}, [highlightRules, groupByCategory])
const openCreate = (): void => setEditor({ open: true, id: undefined })
const openEdit = (target: HighlightRule): void => setEditor({ open: true, id: target.id })
@@ -93,7 +139,52 @@ export function HighlightTab(): React.JSX.Element {
await setHighlightRules(cloneDefaults())
}
/** Only present in the grouped view, where it also explains the row order. */
const categoryColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.category'),
dataIndex: 'category',
width: 96,
align: 'center',
filters: [
...HIGHLIGHT_CATEGORIES.map((category) => ({
text: t(`settings.highlight.category.${category}`),
value: category
})),
{ text: t('settings.highlight.category.none'), value: 'none' }
],
onFilter: (value, record) => (record.category ?? 'none') === value,
render: (_category, record) => <span className="hl-cell-note">{categoryLabel(record.category)}</span>
}
const [statsTick, setStatsTick] = useState(0)
useEffect(() => subscribeHighlightStats(() => setStatsTick((n) => n + 1)), [])
const ruleStats = useMemo(() => getHighlightStats().rules, [statsTick])
/** Only present while the stats switch is on. */
const hitsColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.statsHits'),
key: 'hits',
width: 76,
align: 'center',
sorter: (a, b) => (ruleStats.get(a.id)?.hits ?? 0) - (ruleStats.get(b.id)?.hits ?? 0),
render: (_value, record) => (
<span className="hl-cell-priority">{ruleStats.get(record.id)?.hits ?? 0}</span>
)
}
const msColumn: ColumnsType<HighlightRule>[number] = {
title: t('settings.highlight.statsMs'),
key: 'ms',
width: 88,
align: 'center',
sorter: (a, b) => (ruleStats.get(a.id)?.ms ?? 0) - (ruleStats.get(b.id)?.ms ?? 0),
render: (_value, record) => (
<span className="hl-cell-priority">{formatMs(ruleStats.get(record.id)?.ms ?? 0)}</span>
)
}
const columns: ColumnsType<HighlightRule> = [
...(groupByCategory ? [categoryColumn] : []),
...(statsOn ? [hitsColumn, msColumn] : []),
{
title: t('settings.highlight.enabled'),
dataIndex: 'enabled',
@@ -107,7 +198,7 @@ export function HighlightTab(): React.JSX.Element {
title: t('settings.highlight.pattern'),
dataIndex: 'pattern',
ellipsis: true,
render: (pattern: string) => <PatternCell pattern={pattern} />
render: (_pattern: string, record) => <PatternCell rule={record} />
},
{
title: t('settings.highlight.priority'),
@@ -121,19 +212,30 @@ export function HighlightTab(): React.JSX.Element {
{
title: t('settings.preview'),
dataIndex: 'color',
width: 120,
width: 160,
align: 'center',
render: (color: HighlightRule['color']) => (
<span
className="hl-preview-text"
style={{
color: color.fg,
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
</span>
)
render: (color: HighlightRule['color'], record) =>
record.bands && record.bands.length > 0 ? (
<div className="hl-band-preview">
{record.bands.map((band) => (
<Tooltip key={band.min} title={`≥ ${band.min}`}>
<span className="hl-preview-text" style={{ color: band.fg }}>
≥{band.min}
</span>
</Tooltip>
))}
</div>
) : (
<span
className="hl-preview-text"
style={{
color: color.fg,
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
</span>
)
},
{
title: t('settings.highlight.note'),
@@ -180,6 +282,57 @@ export function HighlightTab(): React.JSX.Element {
message={t('settings.highlight.alert')}
/>
<div className="hl-toolbar">
<Tooltip title={t('settings.highlight.modeDesc')}>
<span className="hl-master">
<span className="hl-editor-sub-label">{t('settings.highlight.mode')}</span>
<Segmented
size="small"
value={highlightMode}
onChange={(value) => void updateTerminal({ highlightMode: value as HighlightMode })}
options={[
{ value: 'all', label: t('settings.highlight.modeAll') },
{ value: 'basic', label: t('settings.highlight.modeBasic') },
{ value: 'off', label: t('settings.highlight.modeOff') }
]}
/>
</span>
</Tooltip>
<Tooltip title={t('settings.highlight.groupByCategory')}>
<span className="hl-master">
<Switch
size="small"
checked={groupByCategory}
onChange={(checked) => void updateTerminal({ highlightGroupByCategory: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.groupByCategory')}</span>
</span>
</Tooltip>
<Tooltip title={t('settings.highlight.statsHint')}>
<span className="hl-master">
<Switch
size="small"
checked={statsOn}
onChange={(checked) => void updateTerminal({ highlightStats: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.stats')}</span>
</span>
</Tooltip>
{statsOn && (
<Button size="small" onClick={() => resetHighlightStats()}>
{t('settings.highlight.statsReset')}
</Button>
)}
<Tooltip title={t('settings.highlight.themeColorsHint')}>
<span className="hl-master">
<Switch
size="small"
checked={themeColorsOn}
onChange={(checked) => void updateTerminal({ highlightThemeColors: checked })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.themeColors')}</span>
</span>
</Tooltip>
<Button onClick={() => setIoOpen(true)}>{t('settings.highlight.importExport')}</Button>
<Popconfirm
title={t('settings.highlight.resetTitle')}
description={t('settings.highlight.resetDesc')}
@@ -193,7 +346,9 @@ export function HighlightTab(): React.JSX.Element {
{t('settings.highlight.addRule')}
</Button>
</div>
<div className="hl-table">
<HighlightImportExport open={ioOpen} onClose={() => setIoOpen(false)} />
<HighlightProfiles />
<div className={highlightMode === 'off' ? 'hl-table hl-table-off' : 'hl-table'}>
<Table
size="small"
rowKey="id"
@@ -212,12 +367,17 @@ export function HighlightTab(): React.JSX.Element {
)
}
function PatternCell({ pattern }: { pattern: string }): React.JSX.Element {
const { ok, message } = tryCompile(pattern)
function PatternCell({ rule }: { rule: HighlightRule }): React.JSX.Element {
const { ok, message } = tryCompile(rule.pattern)
return (
<div className="hl-pattern-cell">
<Tooltip title={pattern}>
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{pattern}</span>
{rule.caseInsensitive === true && (
<Tooltip title={t('settings.highlight.caseInsensitive')}>
<span className="hl-case-badge">Aa</span>
</Tooltip>
)}
<Tooltip title={rule.pattern}>
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{rule.pattern}</span>
</Tooltip>
{!ok && (
<Tooltip title={message}>
@@ -243,6 +403,8 @@ function HighlightEditor({
}): React.JSX.Element {
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
const themeColors = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
const theme = useResolvedTheme()
const isCreate = ruleId === undefined
const editing = useMemo(
@@ -255,11 +417,55 @@ function HighlightEditor({
priority: 1,
fg: '#3fb950',
bg: undefined,
caseInsensitive: false,
note: undefined,
enabled: true
})
const [saving, setSaving] = useState(false)
const [showBg, setShowBg] = useState(false)
const [testText, setTestText] = useState('')
/**
* Preview the draft rule *together with* the other rules: a span that some
* earlier rule steals (`done`, claimed by the shell-keyword rule) then shows up
* here rather than surprising the user in the terminal.
*/
const previewRules = useMemo(() => {
const draftRule: HighlightRule = {
id: ruleId ?? 'draft',
pattern: draft.pattern,
enabled: true,
priority: draft.priority,
color: { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) },
...(draft.bands && draft.bands.length > 0 ? { bands: draft.bands } : {}),
...(draft.caseInsensitive ? { caseInsensitive: true } : {})
}
const active = [...highlightRules.filter((rule) => rule.id !== ruleId), draftRule]
// The preview has to show the colours the terminal will use, so the theme
// mapping is applied here exactly as TerminalView applies it.
return compileRules(themeColors ? applyThemeColors(active, theme) : active)
}, [
draft.pattern,
draft.priority,
draft.fg,
draft.bg,
draft.bands,
draft.caseInsensitive,
showBg,
highlightRules,
ruleId,
themeColors,
theme
])
const preview = useMemo(
() =>
previewSpans(
(testText.trim() === '' ? SAMPLE_TEXT : testText).slice(0, PREVIEW_LIMIT),
previewRules
),
[testText, previewRules]
)
// (re)initialize the form each time the modal opens
useEffect(() => {
@@ -274,7 +480,10 @@ function HighlightEditor({
const bg = editing ? editing.color.bg : undefined
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
setDraft({ pattern, priority, fg, bg, note, enabled })
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const bands = editing?.bands
const category = editing?.category
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
setShowBg(bg != null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -284,11 +493,34 @@ function HighlightEditor({
const patch = (partial: Partial<RuleDraft>): void => setDraft((prev) => ({ ...prev, ...partial }))
const addBand = (): void => {
const bands = draft.bands ?? []
const top = bands[bands.length - 1]
// the new row starts above the current top one, so it is usable as-is
patch({ bands: [...bands, { min: top ? top.min + 20 : 0, fg: PRESET_COLORS[0] }] })
}
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
const bands = draft.bands
if (!bands) return
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
}
const removeBand = (index: number): void => {
const bands = draft.bands
if (!bands) return
const next = bands.filter((_, i) => i !== index)
// dropping the last band switches the rule back to a single colour
patch({ bands: next.length > 0 ? next : undefined })
}
const handleSave = async (): Promise<void> => {
if (!valid) return
setSaving(true)
try {
const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) }
const caseInsensitive = draft.caseInsensitive ? true : undefined
const bands = draft.bands && draft.bands.length > 0 ? draft.bands : undefined
if (isCreate) {
const rule: HighlightRule = {
id: crypto.randomUUID(),
@@ -296,6 +528,9 @@ function HighlightEditor({
priority: draft.priority,
enabled: draft.enabled,
color,
bands,
category: draft.category,
caseInsensitive,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
@@ -306,6 +541,9 @@ function HighlightEditor({
priority: draft.priority,
enabled: draft.enabled,
color,
bands,
category: draft.category,
caseInsensitive,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
@@ -343,6 +581,35 @@ function HighlightEditor({
{!compile.ok && compile.message != null && (
<div className="hl-editor-error">{compile.message}</div>
)}
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.caseInsensitive}
onChange={(c) => patch({ caseInsensitive: c })}
/>
<span className="hl-editor-sub-label">{t('settings.highlight.caseInsensitive')}</span>
<span className="hl-editor-hint">{t('settings.highlight.caseInsensitiveHint')}</span>
</div>
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.testText')}</span>
<div className="hl-editor-control">
<Input.TextArea
value={testText}
onChange={(e) => setTestText(e.target.value)}
placeholder={SAMPLE_TEXT}
autoSize={{ minRows: 1, maxRows: 3 }}
/>
<div className="hl-preview-line">
{preview.map((span, index) => (
<span key={index} style={{ color: span.fg, background: span.bg }}>
{span.text}
</span>
))}
</div>
<span className="hl-editor-hint">{t('settings.highlight.testHint')}</span>
</div>
</div>
@@ -388,6 +655,48 @@ function HighlightEditor({
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.bands')}</span>
<div className="hl-editor-control">
<div className="hl-editor-inline">
<Switch
size="small"
checked={draft.bands != null}
onChange={(c) =>
patch({ bands: c ? (draft.bands ?? [{ min: 0, fg: draft.fg }]) : undefined })
}
/>
<span className="hl-editor-hint">{t('settings.highlight.bandsHint')}</span>
</div>
{draft.bands?.map((band, index) => (
<div className="hl-editor-inline" key={index}>
<span className="hl-editor-sub-label">{t('settings.highlight.bandMin')}</span>
<InputNumber
size="small"
value={band.min}
onChange={(v) => updateBand(index, { min: typeof v === 'number' ? v : 0 })}
style={{ width: 88 }}
/>
<ColorField value={band.fg} onChange={(v) => updateBand(index, { fg: v })} />
<Button
size="small"
title={t('settings.highlight.removeBand')}
onClick={() => removeBand(index)}
>
×
</Button>
</div>
))}
{draft.bands != null && (
<div className="hl-editor-inline">
<Button size="small" onClick={addBand}>
{t('settings.highlight.addBand')}
</Button>
</div>
)}
</div>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.palette')}</span>
<div className="hl-palette">
@@ -413,6 +722,22 @@ function HighlightEditor({
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.category')}</span>
<Select
size="small"
allowClear
style={{ width: 150 }}
value={draft.category}
placeholder={t('settings.highlight.category.none')}
onChange={(value) => patch({ category: value ?? undefined })}
options={HIGHLIGHT_CATEGORIES.map((category) => ({
value: category,
label: t(`settings.highlight.category.${category}`)
}))}
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
+117
View File
@@ -69,6 +69,19 @@
cursor: help;
}
/* marks a rule that matches without regard to letter case */
.hl-case-badge {
flex: 0 0 auto;
padding: 0 4px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 10px;
line-height: 14px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
cursor: help;
}
/* ---- misc cells ---- */
.hl-cell-priority {
@@ -96,6 +109,110 @@
min-width: 74px;
}
/* master switch sits on the left of the toolbar */
.hl-master {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: auto;
cursor: help;
}
/* rules are kept while highlighting is off — say so visually */
.hl-table-off {
opacity: 0.45;
}
/* live preview line under the test-text box */
.hl-preview-line {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
min-height: 24px;
padding: 6px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
}
/* ---- per-host rule sets ---- */
.hl-profiles {
display: flex;
flex-direction: column;
gap: 8px;
}
.hl-profiles-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* the rule picker can hold 20+ entries; keep the dialog from growing */
.hl-profile-rules {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
.hl-profile-rules .ant-checkbox-wrapper {
align-items: flex-start;
font-size: 12px;
word-break: break-all;
}
/* ---- import / export dialog ---- */
.hl-io {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
}
.hl-io-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.hl-io-label {
font-size: 12px;
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
}
.hl-io-json textarea {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.45;
}
/* the file input is driven by the button next to it */
.hl-io-file input {
display: none;
}
/* ---- value-banded rule: one chip per threshold, in that band's colour ---- */
.hl-band-preview {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 2px 6px;
}
.hl-band-preview .hl-preview-text {
font-size: 11px;
font-weight: 500;
min-width: 0;
}
.hl-actions {
display: flex;
justify-content: center;
+23
View File
@@ -0,0 +1,23 @@
import { t } from '@shared/i18n'
import type { HighlightRule } from '@shared/settings'
/**
* Labels for a rule in the settings UI.
*
* Built-in rules store a zh-CN note and carry a `settings.highlight.builtin.<id>`
* translation; `t()` returns the key itself when a translation is missing, which
* is exactly how the two are told apart here.
*/
/** Translated note for a built-in rule, or the stored note for a user rule. */
export function ruleNote(rule: HighlightRule): string | undefined {
const key = `settings.highlight.builtin.${rule.id}`
const translated = t(key)
if (translated !== key) return translated
return rule.note !== undefined && rule.note !== '' ? rule.note : undefined
}
/** Label for a rule in a picker: its note, falling back to the pattern itself. */
export function ruleLabel(rule: HighlightRule): string {
return ruleNote(rule) ?? rule.pattern
}
+11
View File
@@ -15,6 +15,8 @@ export interface SettingsState {
setCustomThemes: (themes: AppSettings['customThemes']) => Promise<void>
/** replace highlightRules array and persist */
setHighlightRules: (rules: AppSettings['highlightRules']) => Promise<void>
/** replace highlightProfiles array and persist */
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
/** shallow-merge into settings.system and persist */
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
}
@@ -56,6 +58,9 @@ async function persist(
const patch: Partial<AppSettings> = {}
if (written.customThemes !== prev.customThemes) patch.customThemes = written.customThemes
if (written.highlightRules !== prev.highlightRules) patch.highlightRules = written.highlightRules
if (written.highlightProfiles !== prev.highlightProfiles) {
patch.highlightProfiles = written.highlightProfiles
}
const terminal = diffGroup(prev.terminal, written.terminal)
// Main merges each group one level deep, so a group holding only the
// changed sub-keys is a valid patch payload even though the contract types
@@ -116,6 +121,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
await persist(get, set, next, prev, 'setHighlightRules')
},
setHighlightProfiles: async (profiles) => {
const prev = get().settings
const next: AppSettings = { ...prev, highlightProfiles: profiles }
await persist(get, set, next, prev, 'setHighlightProfiles')
},
updateSystem: async (partial) => {
const prev = get().settings
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
+83 -5
View File
@@ -11,10 +11,13 @@ import { Checkbox, Modal } from 'antd'
import { t } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream } from './highlightEngine'
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats'
import { applyThemeColors } from '@renderer/theme/highlightColors'
import { findUrls } from './urlLinks'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
@@ -98,6 +101,8 @@ export interface TerminalViewProps {
className?: string
/** ssh sessions have a remote cwd — the open-directory button is hidden */
isSsh?: boolean
/** saved connection behind an ssh session, used to look up its highlight profile */
connectionId?: string
}
interface SearchBarProps {
@@ -256,7 +261,10 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
}
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className, isSsh = false }, ref) {
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner(
{ sessionId, onClose, className, isSsh = false, connectionId },
ref
) {
const settings = useSettingsStore((s) => s.settings)
const hostRef = useRef<HTMLDivElement | null>(null)
@@ -301,7 +309,75 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// the (stable, single-construction) pty/term subscriptions always read the
// latest rules without triggering a terminal rebuild. Only new output is
// highlighted — the existing scrollback buffer is left untouched.
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
//
// The mode empties the rule set instead of bypassing the stream: the stream
// holds a trailing text run back, and skipping it would drop those bytes. An
// empty set is ~free — applyHighlights returns the chunk before it tokenizes.
const highlightMode = highlightModeOf(settings.terminal.highlightMode)
const highlightTheme = useResolvedTheme()
// Per-host profiles (opt-in): an SSH session bound to a profile runs only that
// subset. The binding lives on the saved connection, so it is looked up once
// per session — and only while the feature is switched on.
const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined)
useEffect(() => {
if (connectionId === undefined || !settings.terminal.highlightPerHost) {
setBoundProfileId(undefined)
return undefined
}
let cancelled = false
void window.api
.listConnections()
.then((list) => {
if (!cancelled) {
setBoundProfileId(list.find((conn) => conn.id === connectionId)?.highlightProfileId)
}
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [connectionId, settings.terminal.highlightPerHost])
const compiledRules = useMemo(() => {
if (highlightMode === 'off') return []
const active =
highlightMode === 'basic'
? settings.highlightRules.filter((rule) => rule.basic === true)
: settings.highlightRules
const perHost = settings.terminal.highlightPerHost
? rulesForProfile(active, boundProfileId, settings.highlightProfiles)
: active
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
// colour comes from the theme the terminal is actually drawn with.
return compileRules(
settings.terminal.highlightThemeColors ? applyThemeColors(perHost, highlightTheme) : perHost
)
}, [
highlightMode,
settings.highlightRules,
settings.highlightProfiles,
settings.terminal.highlightThemeColors,
settings.terminal.highlightPerHost,
boundProfileId,
highlightTheme
])
// Optional per-rule stats (settings → highlight → "count hits and time").
// Attached and detached here, so toggling the switch never rebuilds the
// terminal, and published on a timer — a busy terminal would otherwise
// re-render the settings page once per chunk.
const statsRef = useRef<Map<string, RuleStat> | null>(null)
useEffect(() => {
const sink = settings.terminal.highlightStats
? (statsRef.current ?? new Map<string, RuleStat>())
: null
statsRef.current = sink
streamRef.current?.setStats(sink ?? undefined)
if (sink === null) return undefined
publishHighlightStats(new Map(sink))
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats])
streamRef.current?.setRules(compiledRules)
// M5: load completion candidates once per session (history + library cached).
@@ -767,6 +843,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// across a sessionId rebind (e.g. template apply).
const stream = new HighlightStream(compiledRules)
streamRef.current = stream
// A session created while stats are on keeps collecting into the same map.
stream.setStats(statsRef.current ?? undefined)
// The stream holds a trailing text run for cross-chunk anchor consistency.
// A shell prompt is exactly such a run with no follow-up data, so flush the
// carry on a short timer — prompts render within a frame, anchors survive.
+124 -15
View File
@@ -12,16 +12,32 @@ import type { HighlightRule } from '@shared/settings'
export interface CompiledRule {
id: string
/** compiled with the 'g' flag, pattern's own flags otherwise untouched */
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
regex: RegExp
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
fg: string
/** optional hex bg */
bg?: string
/** optional value bands, ascending by `min` — see HighlightRule.bands */
bands?: { min: number; fg: string }[]
/** copied from the source rule for stable priority ordering */
priority: number
}
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
export interface RuleStat {
/** matches that were actually coloured */
hits: number
/** wall time spent scanning with this rule, milliseconds */
ms: number
}
/**
* Optional sink for the settings page's stats view. Absent on the default path,
* so an ordinary terminal write pays nothing for the counters.
*/
export type StatsSink = Map<string, RuleStat>
type Token = { kind: 'seq' | 'text'; value: string }
type Span = { start: number; end: number; fg: string; bg?: string }
@@ -84,20 +100,36 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
if (typeof pattern !== 'string' || pattern.length === 0) continue
let regex: RegExp
try {
// 'g' always; case-sensitivity left to the pattern (no automatic 'i').
regex = new RegExp(pattern, 'g')
// 'g' always; 'i' when the rule asks for case-insensitive matching.
// Lookbehinds (used by the preset success rule to leave `not ok` alone)
// need no special handling — they are plain V8 regex syntax.
regex = new RegExp(pattern, rule.caseInsensitive === true ? 'gi' : 'g')
} catch {
continue // invalid pattern → silently skip
}
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority })
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
}
out.sort((a, b) => a.priority - b.priority)
return out
}
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
if (!Array.isArray(bands)) return undefined
const kept: { min: number; fg: string }[] = []
for (const band of bands) {
if (!band || !Number.isFinite(band.min)) continue
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
kept.push({ min: band.min, fg: band.fg })
}
if (kept.length === 0) return undefined
kept.sort((a, b) => a.min - b.min)
return kept
}
/** Wrap one plain-text span with truecolor SGR. */
function wrap(text: string, fgHex: string, bgHex?: string): string {
const f = hexToRgb(fgHex)
@@ -117,6 +149,26 @@ function overlaps(claimed: Span[], s: number, e: number): boolean {
return false
}
/**
* Colour for one match: with value bands the match's first number picks the
* band (last `min` that is <= the value wins); everything else keeps the rule's
* own foreground.
*/
function bandColor(rule: CompiledRule, matched: string): string {
const bands = rule.bands
if (!bands || bands.length === 0) return rule.fg
const number = /-?\d+(?:\.\d+)?/.exec(matched)
if (number === null) return rule.fg
const value = Number(number[0])
if (!Number.isFinite(value)) return rule.fg
let fg = rule.fg
for (const band of bands) {
if (value < band.min) break
fg = band.fg
}
return fg
}
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
function hasLongLine(text: string): boolean {
let start = 0
@@ -134,13 +186,17 @@ function hasLongLine(text: string): boolean {
* SGR-injected text. With a global budget of zero for every rule the run is
* returned untouched, as is a run holding an over-long line (see MAX_LINE_LEN).
*/
function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string {
/** Millisecond clock for the optional stats sink; `performance` when present. */
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink): string {
if (text.length === 0) return ''
if (hasLongLine(text)) return text
const claimed: Span[] = []
for (let ri = 0; ri < rules.length; ri++) {
const rule = rules[ri]
if (budgets[ri] <= 0) continue
const started = stats === undefined ? 0 : now()
rule.regex.lastIndex = 0
let m: RegExpExecArray | null
let made = 0
@@ -153,13 +209,19 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
continue
}
if (!overlaps(claimed, start, end)) {
claimed.push({ start, end, fg: rule.fg, bg: rule.bg })
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
made++
budgets[ri]--
if (budgets[ri] <= 0) break
}
if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse
}
if (stats !== undefined) {
const stat = stats.get(rule.id) ?? { hits: 0, ms: 0 }
stat.hits += made
stat.ms += now() - started
stats.set(rule.id, stat)
}
}
if (claimed.length === 0) return text
claimed.sort((a, b) => a.start - b.start)
@@ -177,8 +239,10 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
/**
* Rewrite `chunk` with truecolor highlights. Returns the input unchanged when
* there are no enabled rules, the chunk is empty, or it exceeds the budget.
* With a `stats` sink attached, per-rule hit counts and scan times are added to
* it — the settings page's stats view is the only caller that passes one.
*/
export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: StatsSink): string {
if (!chunk || rules.length === 0) return chunk
if (chunk.length > MAX_CHUNK) return chunk
@@ -197,15 +261,54 @@ export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
continue
}
const before = budgets.reduce((a, b) => a + b, 0)
out += applyRun(token.value, rules, budgets)
out += applyRun(token.value, rules, budgets, stats)
const after = budgets.reduce((a, b) => a + b, 0)
if (after < before) usedBudget = true
}
return usedBudget ? out : chunk
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */
function isIncompleteEscape(seg: string): boolean {
/** One coloured or plain segment of a preview line. */
export interface PreviewSpan {
text: string
fg?: string
bg?: string
}
/** "#rrggbb" from three decimal SGR colour components. */
function toHex(r: string, g: string, b: string): string {
const part = (value: string): string => Number(value).toString(16).padStart(2, '0')
return `#${part(r)}${part(g)}${part(b)}`
}
/**
* Split `text` into coloured and plain segments for the settings preview.
*
* It runs the real highlighter and parses its own SGR output instead of
* re-implementing the matching, so the editor preview cannot drift from what the
* terminal actually receives. Escape sequences already in the sample are
* stripped first: this shows text the user typed, not a captured stream.
*/
export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[] {
const out = applyHighlights(text.replace(ESCAPE_RE, ''), rules)
const spans: PreviewSpan[] = []
const SGR = /\x1b\[38;2;(\d+);(\d+);(\d+)(?:;48;2;(\d+);(\d+);(\d+))?m([\s\S]*?)\x1b\[0m/g
let last = 0
let m: RegExpExecArray | null
while ((m = SGR.exec(out)) !== null) {
if (m.index > last) spans.push({ text: out.slice(last, m.index) })
spans.push({
text: m[7],
fg: toHex(m[1], m[2], m[3]),
...(m[4] !== undefined ? { bg: toHex(m[4], m[5], m[6]) } : {})
})
last = m.index + m[0].length
}
if (last < out.length) spans.push({ text: out.slice(last) })
return spans
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
// `seg` is expected to start at (or be) a lone ESC.
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
if (seg === '\x1b') return true
@@ -251,6 +354,7 @@ const MAX_CARRY = 1024
*/
export class HighlightStream {
private rules: CompiledRule[]
private stats?: StatsSink
private buffer = ''
constructor(rules: CompiledRule[]) {
@@ -262,6 +366,11 @@ export class HighlightStream {
this.rules = rules
}
/** Attach or drop the optional stats sink (see `highlightStats`). */
setStats(stats: StatsSink | undefined): void {
this.stats = stats
}
/** True while content is held back (a pending flush will release it). */
hasPending(): boolean {
return this.buffer.length > 0
@@ -280,10 +389,10 @@ export class HighlightStream {
// Give up waiting on a pathologically long unterminated sequence: emit
// the fragment raw (applyHighlights skips raw \x1b in text runs).
this.buffer = ''
return applyHighlights(head, this.rules) + tail
return applyHighlights(head, this.rules, this.stats) + tail
}
this.buffer = tail
return applyHighlights(head, this.rules)
return applyHighlights(head, this.rules, this.stats)
}
// (b) no incomplete escape -> find the last *closed* escape and emit only
@@ -295,20 +404,20 @@ export class HighlightStream {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
return applyHighlights(raw, this.rules)
return applyHighlights(raw, this.rules, this.stats)
}
return ''
}
const head = this.buffer.slice(0, lastEnd)
this.buffer = this.buffer.slice(lastEnd)
return applyHighlights(head, this.rules)
return applyHighlights(head, this.rules, this.stats)
}
/** Flush any held content (session end) — highlighted as a final run. */
flush(): string {
const b = this.buffer
this.buffer = ''
return applyHighlights(b, this.rules)
return applyHighlights(b, this.rules, this.stats)
}
}
@@ -0,0 +1,43 @@
import type { RuleStat } from './highlightEngine'
/**
* Live per-rule highlight counters, read by the settings page.
*
* The engine fills these only when a sink is attached, and `TerminalView`
* attaches one only while `settings.terminal.highlightStats` is on — so the
* default write path pays nothing. Totals are cumulative since the last reset;
* TerminalView publishes on a timer (not per chunk), because a busy terminal
* would otherwise re-render the settings page thousands of times a second.
*/
let totals: Map<string, RuleStat> = new Map()
let updatedAt = 0
const listeners = new Set<() => void>()
export interface HighlightStatsSnapshot {
rules: Map<string, RuleStat>
/** epoch ms of the last publish; the UI re-renders on change */
updatedAt: number
}
export function getHighlightStats(): HighlightStatsSnapshot {
return { rules: totals, updatedAt }
}
/** Hand the accumulated counters to the UI (TerminalView owns the map). */
export function publishHighlightStats(next: Map<string, RuleStat>): void {
totals = next
updatedAt = Date.now()
for (const listener of listeners) listener()
}
export function resetHighlightStats(): void {
publishHighlightStats(new Map())
}
export function subscribeHighlightStats(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
+87
View File
@@ -0,0 +1,87 @@
import { hexToRgb } from '../terminal/highlightEngine'
import type { HighlightRule } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
/**
* Optional "follow the theme" colour mapping for highlight rules.
*
* Rules ship with hand-picked hex colours that read on any theme, but a user on
* Solarized or Gruvbox usually wants highlighting drawn from *their* palette.
* The mapping buckets a colour by hue instead of picking the nearest swatch, so
* the intent survives: a success green stays a green, an error red stays a red.
* Brightness selects the normal or bright palette entry, which is what keeps a
* two-green value band (50–80% light green vs 80%+ green) distinguishable.
*
* Muted colours (grey-ish, like the 1xx status grey) are left untouched — there
* is no hue to follow, and the palette has no neutral that means "quieter".
*/
/** ANSI palette entries a highlight colour can land on. */
type PaletteName = 'red' | 'yellow' | 'green' | 'cyan' | 'blue' | 'magenta'
export interface PaletteMatch {
name: PaletteName
bright: boolean
}
/** Below this saturation a colour counts as neutral and keeps its own value. */
const NEUTRAL_SATURATION = 0.15
/** Above this lightness the "bright" palette entry is used. */
const BRIGHT_LIGHTNESS = 0.6
/** Bucket a colour by hue/brightness, or `null` for neutrals. */
export function paletteMatch(hex: string): PaletteMatch | null {
const { r, g, b } = hexToRgb(hex)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
if (max === 0 || (max - min) / max < NEUTRAL_SATURATION) return null
const lightness = (max + min) / 2 / 255
const delta = max - min
let hue: number
if (max === r) hue = ((g - b) / delta) % 6
else if (max === g) hue = (b - r) / delta + 2
else hue = (r - g) / delta + 4
hue = (hue * 60 + 360) % 360
const name: PaletteName =
hue < 25 || hue >= 335
? 'red'
: hue < 70
? 'yellow'
: hue < 165
? 'green'
: hue < 200
? 'cyan'
: hue < 265
? 'blue'
: 'magenta'
return { name, bright: lightness >= BRIGHT_LIGHTNESS }
}
/** The theme's colour for a rule colour, or the rule colour when it has no hue. */
export function themeColorFor(hex: string, theme: TerminalTheme): string {
const match = paletteMatch(hex)
if (match === null) return hex
const key = (
match.bright ? `bright${match.name[0].toUpperCase()}${match.name.slice(1)}` : match.name
) as keyof ThemeColors
const color = theme.colors[key]
return typeof color === 'string' && color !== '' ? color : hex
}
/**
* Rewrite a rule set onto the theme's palette. Backgrounds are left alone on
* purpose: they are a block behind the text, not a semantic signal, and mapping
* them to a palette *foreground* colour would invert their meaning.
*/
export function applyThemeColors(rules: HighlightRule[], theme: TerminalTheme): HighlightRule[] {
return rules.map((rule) => ({
...rule,
color: { fg: themeColorFor(rule.color.fg, theme), ...(rule.color.bg ? { bg: rule.color.bg } : {}) },
...(rule.bands
? { bands: rule.bands.map((band) => ({ ...band, fg: themeColorFor(band.fg, theme) })) }
: {})
}))
}
@@ -67,6 +67,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
<TerminalView
sessionId={sessionId}
isSsh={isSsh}
connectionId={params.connectionId}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
+4 -2
View File
@@ -55,6 +55,8 @@ interface TerminalParams {
sessionId?: string
sessionKind?: 'local' | 'ssh'
hostLabel?: string
/** saved SSH connection this session came from, for per-host highlight profiles */
connectionId?: string
}
export type { TerminalParams }
@@ -744,7 +746,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: conn.name,
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host, connectionId: conn.id }
})
// B's sidebar refreshes lastConnectedAt on connect.
void sidebarRef.current?.refresh()
@@ -834,7 +836,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: reference.title,
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel },
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel, connectionId: refParams?.connectionId },
position: { referencePanel: reference.id, direction }
})
},