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
@@ -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 }
|
||||
})
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user