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
@@ -34,6 +34,7 @@ interface StoredConnection {
|
||||
askPassphraseAtConnect: boolean
|
||||
keyPath?: string
|
||||
keepaliveIntervalSec: number
|
||||
highlightProfileId?: string
|
||||
createdAt: number
|
||||
lastConnectedAt?: number
|
||||
password_enc?: string
|
||||
@@ -52,7 +53,8 @@ const PUBLIC_KEYS = [
|
||||
'askPasswordAtConnect',
|
||||
'askPassphraseAtConnect',
|
||||
'keyPath',
|
||||
'keepaliveIntervalSec'
|
||||
'keepaliveIntervalSec',
|
||||
'highlightProfileId'
|
||||
] as const satisfies readonly (keyof Omit<StoredConnection, 'password_enc' | 'keyContent_enc' | 'passphrase_enc'>)[]
|
||||
|
||||
function encrypt(plain: string): string {
|
||||
@@ -96,6 +98,7 @@ function toPublic(stored: StoredConnection): SshConnection {
|
||||
askPassphraseAtConnect: stored.askPassphraseAtConnect,
|
||||
keyPath: stored.keyPath,
|
||||
keepaliveIntervalSec: stored.keepaliveIntervalSec,
|
||||
highlightProfileId: stored.highlightProfileId,
|
||||
createdAt: stored.createdAt,
|
||||
lastConnectedAt: stored.lastConnectedAt,
|
||||
savedAuth: {
|
||||
@@ -175,6 +178,7 @@ export class ConnectionsStore {
|
||||
askPassphraseAtConnect: input.askPassphraseAtConnect,
|
||||
keyPath: input.keyPath,
|
||||
keepaliveIntervalSec: input.keepaliveIntervalSec,
|
||||
highlightProfileId: input.highlightProfileId,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
list.push(stored)
|
||||
|
||||
+126
-3
@@ -5,6 +5,7 @@ import { Ipc } from '../shared/ipc'
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_RULES,
|
||||
DEFAULT_SETTINGS,
|
||||
isHighlightCategory,
|
||||
type AppSettings,
|
||||
type HighlightRule,
|
||||
type SystemSettings,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from '../shared/settings'
|
||||
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
|
||||
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
|
||||
import { sanitizeProfiles } from '../shared/highlightProfiles'
|
||||
import { broadcast } from './broadcast'
|
||||
import { applyGlobalShortcut } from './globalShortcuts'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
@@ -80,6 +82,39 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
|
||||
warnings.push(`highlightRules[${index}].color repaired → ${DEFAULT_RULE_COLOR}`)
|
||||
}
|
||||
|
||||
if (rule.caseInsensitive !== undefined && typeof rule.caseInsensitive !== 'boolean') {
|
||||
warnings.push(`highlightRules[${index}].caseInsensitive ignored (not a boolean)`)
|
||||
}
|
||||
if (rule.basic !== undefined && typeof rule.basic !== 'boolean') {
|
||||
warnings.push(`highlightRules[${index}].basic ignored (not a boolean)`)
|
||||
}
|
||||
if (rule.category !== undefined && !isHighlightCategory(rule.category)) {
|
||||
warnings.push(`highlightRules[${index}].category ignored (unknown value)`)
|
||||
}
|
||||
|
||||
// Value bands are repaired one entry at a time: a band without a usable min or
|
||||
// colour is dropped, and what survives is sorted so the engine can walk it.
|
||||
let bands: { min: number; fg: string }[] | undefined
|
||||
if (rule.bands !== undefined) {
|
||||
if (Array.isArray(rule.bands)) {
|
||||
const kept: { min: number; fg: string }[] = []
|
||||
rule.bands.forEach((entry, bandIndex) => {
|
||||
const band = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>) : null
|
||||
const min = typeof band?.min === 'number' ? band.min : Number(band?.min)
|
||||
const fg = typeof band?.fg === 'string' ? band.fg.trim() : ''
|
||||
if (band === null || !Number.isFinite(min) || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) {
|
||||
warnings.push(`highlightRules[${index}].bands[${bandIndex}]: unusable — skipped`)
|
||||
return
|
||||
}
|
||||
kept.push({ min, fg })
|
||||
})
|
||||
kept.sort((a, b) => a.min - b.min)
|
||||
if (kept.length > 0) bands = kept
|
||||
} else {
|
||||
warnings.push(`highlightRules[${index}].bands ignored (not an array)`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
id: rule.id,
|
||||
@@ -87,10 +122,82 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
|
||||
enabled,
|
||||
priority,
|
||||
color,
|
||||
bands,
|
||||
// `undefined` rather than `false` so the flag stays absent unless it is on.
|
||||
caseInsensitive: rule.caseInsensitive === true ? true : undefined,
|
||||
basic: rule.basic === true ? true : undefined,
|
||||
category: isHighlightCategory(rule.category) ? rule.category : undefined,
|
||||
note: typeof rule.note === 'string' ? rule.note : undefined
|
||||
} as HighlightRule
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule
|
||||
* whose pattern is still one of these was never edited by the user, so the
|
||||
* load-time refresh below may swap in the current preset for it.
|
||||
*/
|
||||
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
|
||||
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
|
||||
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
|
||||
}
|
||||
|
||||
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
|
||||
const LEGACY_BUILTIN_IDS = [
|
||||
'perm',
|
||||
'path',
|
||||
'shellkw',
|
||||
'okstate',
|
||||
'badstate',
|
||||
'quoted',
|
||||
'envvar',
|
||||
'ipv4',
|
||||
'datetime',
|
||||
'numbers',
|
||||
'url'
|
||||
]
|
||||
|
||||
/**
|
||||
* Carry an older install's preset rules onto the current ones.
|
||||
*
|
||||
* An upgrade must not throw the user's own work away, so both steps are guarded:
|
||||
* - a built-in rule still holding a shipped pattern is refreshed in place; the
|
||||
* pattern and case flag come from the preset, while id, colour, priority,
|
||||
* note and the enabled switch stay exactly as the user left them;
|
||||
* - preset rules that did not exist yet are appended only while the stored set
|
||||
* is still exactly the older preset set (nothing deleted, nothing added) —
|
||||
* the moment the list is the user's own, a rule they removed stays removed.
|
||||
* Both steps are idempotent: a refreshed pattern no longer matches a legacy one,
|
||||
* and an upgraded set never matches the older preset id list again.
|
||||
*/
|
||||
function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): HighlightRule[] {
|
||||
const presets = new Map(DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, rule]))
|
||||
let changed = false
|
||||
const refreshed = rules.map((rule) => {
|
||||
const legacy = LEGACY_BUILTIN_PATTERNS[rule.id]
|
||||
const preset = presets.get(rule.id)
|
||||
if (!legacy || preset === undefined || rule.builtin !== true || !legacy.includes(rule.pattern)) {
|
||||
return rule
|
||||
}
|
||||
changed = true
|
||||
warnings.push(`highlightRules.${rule.id}: preset pattern updated`)
|
||||
return { ...rule, pattern: preset.pattern, caseInsensitive: preset.caseInsensitive }
|
||||
})
|
||||
|
||||
// Presets added since are appended only while the stored set is *exactly* the
|
||||
// older preset set. Anything else — a rule deleted, one added by hand — means
|
||||
// the set is the user's, and a preset they threw away must not come back on
|
||||
// every load.
|
||||
const present = refreshed.map((rule) => rule.id)
|
||||
const pristine =
|
||||
present.length === LEGACY_BUILTIN_IDS.length &&
|
||||
LEGACY_BUILTIN_IDS.every((id) => present.includes(id))
|
||||
if (!pristine) return changed ? refreshed : rules
|
||||
const missing = DEFAULT_HIGHLIGHT_RULES.filter((rule) => !present.includes(rule.id))
|
||||
if (missing.length === 0) return changed ? refreshed : rules
|
||||
for (const rule of missing) warnings.push(`highlightRules.${rule.id}: new built-in rule added`)
|
||||
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
|
||||
// An explicit empty array is a valid choice ("no highlighting"); only
|
||||
// malformed data falls back to the built-in rules. Returning the defaults for
|
||||
@@ -101,7 +208,7 @@ function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
|
||||
const rule = coerceRule(entry, index, warnings)
|
||||
if (rule) rules.push(rule)
|
||||
})
|
||||
return rules
|
||||
return refreshBuiltinRules(rules, warnings)
|
||||
}
|
||||
|
||||
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
|
||||
@@ -165,9 +272,15 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
||||
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
|
||||
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
|
||||
let highlightProfiles: unknown = []
|
||||
|
||||
if (raw !== null && typeof raw === 'object') {
|
||||
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
|
||||
const packageSettings = raw as {
|
||||
terminal?: unknown
|
||||
customThemes?: unknown
|
||||
highlightRules?: unknown
|
||||
highlightProfiles?: unknown
|
||||
}
|
||||
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
|
||||
const candidate = packageSettings.terminal as Record<string, unknown>
|
||||
const merged: Record<string, unknown> = { ...terminal }
|
||||
@@ -187,6 +300,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
if (packageSettings.highlightRules !== undefined) {
|
||||
highlightRules = packageSettings.highlightRules
|
||||
}
|
||||
if (packageSettings.highlightProfiles !== undefined) {
|
||||
highlightProfiles = packageSettings.highlightProfiles
|
||||
}
|
||||
}
|
||||
|
||||
let system: unknown = { ...DEFAULT_SYSTEM }
|
||||
@@ -217,12 +333,18 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
}
|
||||
|
||||
const themes = sanitizeThemes(customThemes, errors)
|
||||
const rules = sanitizeRules(highlightRules, errors)
|
||||
|
||||
return {
|
||||
settings: {
|
||||
terminal,
|
||||
customThemes: themes,
|
||||
highlightRules: sanitizeRules(highlightRules, errors),
|
||||
highlightRules: rules,
|
||||
highlightProfiles: sanitizeProfiles(
|
||||
highlightProfiles,
|
||||
new Set(rules.map((rule) => rule.id)),
|
||||
(message) => errors.push(message)
|
||||
),
|
||||
system: system as SystemSettings
|
||||
},
|
||||
errors
|
||||
@@ -296,6 +418,7 @@ export function loadSettings(): AppSettings {
|
||||
terminal: { ...DEFAULT_SETTINGS.terminal },
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
|
||||
highlightProfiles: [],
|
||||
system: { ...DEFAULT_SYSTEM }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
|
||||
? rawGroup.trim()
|
||||
: undefined
|
||||
|
||||
const rawProfile = values.highlightProfileId
|
||||
const highlightProfileId =
|
||||
typeof rawProfile === 'string' && rawProfile.trim() !== '' ? rawProfile.trim() : undefined
|
||||
|
||||
const input: SshConnectionInput = {
|
||||
id: ctx.conn?.id,
|
||||
name: String(values.name ?? '').trim(),
|
||||
@@ -67,7 +71,8 @@ export function buildInput(values: Record<string, unknown>, ctx: BuildCtx): SshC
|
||||
askPasswordAtConnect,
|
||||
askPassphraseAtConnect,
|
||||
keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30,
|
||||
group
|
||||
group,
|
||||
highlightProfileId
|
||||
}
|
||||
|
||||
const secret = (key: SecretKind): string | undefined => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd'
|
||||
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections'
|
||||
import { getLanguage, t } from '@shared/i18n'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { AuthFieldsRenderer } from './Fields'
|
||||
import { buildInput, num, hasSavedKind, type SecretKind } from './Common'
|
||||
|
||||
@@ -55,6 +56,9 @@ export function ConnectionEditDialog({
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Named highlight-rule subsets defined in Settings, for the profile Select.
|
||||
const highlightProfiles = useSettingsStore((s) => s.settings.highlightProfiles)
|
||||
|
||||
// Populate / clear the form for each open + target change.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -76,7 +80,8 @@ export function ConnectionEditDialog({
|
||||
askPasswordAtConnect: editing?.askPasswordAtConnect,
|
||||
askPassphraseAtConnect: editing?.askPassphraseAtConnect,
|
||||
keyPath: editing?.keyPath,
|
||||
keepaliveIntervalSec: editing?.keepaliveIntervalSec
|
||||
keepaliveIntervalSec: editing?.keepaliveIntervalSec,
|
||||
highlightProfileId: editing?.highlightProfileId
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, editKey, form])
|
||||
@@ -214,6 +219,14 @@ export function ConnectionEditDialog({
|
||||
>
|
||||
<InputNumber className="connections-keepalive-input" min={0} placeholder="30" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="highlightProfileId" label={t('ssh.dialog.highlightProfile')}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('ssh.dialog.highlightProfileDefault')}
|
||||
options={highlightProfiles.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
askPassphraseAtConnect: input.askPassphraseAtConnect,
|
||||
keyPath: input.keyPath,
|
||||
keepaliveIntervalSec: input.keepaliveIntervalSec,
|
||||
highlightProfileId: input.highlightProfileId,
|
||||
createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false }
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
|
||||
import type { ImportError } from '@shared/highlightIO'
|
||||
import { useSettingsStore } from './store'
|
||||
|
||||
interface ImportResult {
|
||||
error?: ImportError
|
||||
warnings: string[]
|
||||
/** rules that made it in; 0 means nothing was imported */
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Import / export of the rule set as JSON. The renderer can do all of this on
|
||||
* its own (clipboard, FileReader, a download link), so no main-process dialog or
|
||||
* IPC is involved — which also keeps the feature testable outside Electron.
|
||||
*/
|
||||
export function HighlightImportExport({
|
||||
open,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
|
||||
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
|
||||
|
||||
const [draft, setDraft] = useState('')
|
||||
const [mode, setMode] = useState<'replace' | 'append'>('append')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
|
||||
const exported = useMemo(() => exportHighlightRules(highlightRules), [highlightRules])
|
||||
|
||||
// start clean each time the dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDraft('')
|
||||
setResult(null)
|
||||
setCopied(false)
|
||||
}, [open])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exported)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// clipboard can be blocked; the textarea stays selectable by hand
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (): void => {
|
||||
const url = URL.createObjectURL(new Blob([exported], { type: 'application/json' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'openterminal-highlight-rules.json'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleFile = (file: File): void => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const errorText = (error: ImportError): string => {
|
||||
if (error === 'not-json') return t('settings.highlight.importBadJson')
|
||||
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
|
||||
return t('settings.highlight.importEmpty')
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
const parsed = parseHighlightRules(draft)
|
||||
if (parsed.error !== undefined) {
|
||||
setResult({ error: parsed.error, warnings: [], count: 0 })
|
||||
return
|
||||
}
|
||||
if (parsed.rules.length === 0) {
|
||||
setResult({ warnings: parsed.warnings, count: 0 })
|
||||
return
|
||||
}
|
||||
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
|
||||
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
title={t('settings.highlight.importExportTitle')}
|
||||
>
|
||||
<div className="hl-io">
|
||||
<div className="hl-io-section">
|
||||
<div className="hl-io-label">{t('settings.highlight.exportSection')}</div>
|
||||
<Input.TextArea
|
||||
value={exported}
|
||||
readOnly
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
className="hl-io-json"
|
||||
/>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => void handleCopy()}>
|
||||
{copied ? t('settings.highlight.copied') : t('settings.highlight.copy')}
|
||||
</Button>
|
||||
<Button size="small" onClick={handleDownload}>
|
||||
{t('settings.highlight.download')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="hl-io-section">
|
||||
<div className="hl-io-label">{t('settings.highlight.importSection')}</div>
|
||||
<Input.TextArea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
className="hl-io-json"
|
||||
/>
|
||||
<Space wrap>
|
||||
<label className="hl-io-file">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button size="small">{t('settings.highlight.chooseFile')}</Button>
|
||||
</label>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.importMode')}</span>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as 'replace' | 'append')}
|
||||
>
|
||||
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
|
||||
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={draft.trim() === ''}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t('settings.highlight.importOk')}
|
||||
</Button>
|
||||
</Space>
|
||||
{result && (
|
||||
<Alert
|
||||
type={result.error !== undefined || result.count === 0 ? 'error' : 'success'}
|
||||
showIcon
|
||||
message={
|
||||
result.error !== undefined
|
||||
? errorText(result.error)
|
||||
: result.count === 0
|
||||
? t('settings.highlight.importEmpty')
|
||||
: t('settings.highlight.imported', { n: result.count })
|
||||
}
|
||||
description={
|
||||
result.warnings.length > 0
|
||||
? t('settings.highlight.importSkipped', {
|
||||
n: result.warnings.length,
|
||||
list: result.warnings.join('; ')
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { HighlightProfile } from '@shared/settings'
|
||||
import { ruleLabel } from './ruleLabel'
|
||||
import { useSettingsStore } from './store'
|
||||
|
||||
/**
|
||||
* Named subsets of the highlight rules ("profiles"), plus the switch that makes
|
||||
* them apply. A connection bound to a profile runs only that subset, which is how
|
||||
* a production host gets a quieter set than a local shell. Nothing here affects a
|
||||
* session until `highlightPerHost` is on and the connection is bound.
|
||||
*/
|
||||
export function HighlightProfiles(): React.JSX.Element {
|
||||
const profiles = useSettingsStore((s) => s.settings.highlightProfiles)
|
||||
const rules = useSettingsStore((s) => s.settings.highlightRules)
|
||||
const setHighlightProfiles = useSettingsStore((s) => s.setHighlightProfiles)
|
||||
const perHost = useSettingsStore((s) => s.settings.terminal.highlightPerHost)
|
||||
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
|
||||
|
||||
/** null = dialog closed; '' = creating; otherwise the profile being edited. */
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [ruleIds, setRuleIds] = useState<string[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const editing = profiles.find((profile) => profile.id === editingId)
|
||||
|
||||
// seed the dialog each time it opens
|
||||
useEffect(() => {
|
||||
if (editingId === null) return
|
||||
setName(editing?.name ?? '')
|
||||
setRuleIds(editing?.ruleIds ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingId])
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (name.trim() === '') return
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = editing
|
||||
? profiles.map((profile) =>
|
||||
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
|
||||
)
|
||||
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
|
||||
await setHighlightProfiles(next)
|
||||
setEditingId(null)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<HighlightProfile> = [
|
||||
{
|
||||
title: t('settings.highlight.profileName'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('settings.highlight.profileRules'),
|
||||
dataIndex: 'ruleIds',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (ids: string[]) => (
|
||||
<span className="hl-cell-note">
|
||||
{ids.length === 0 ? t('settings.highlight.modeAll') : ids.length}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.highlight.actions'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<div className="hl-actions">
|
||||
<Button size="small" onClick={() => setEditingId(record.id)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('settings.highlight.profileDeleteTitle')}
|
||||
description={t('settings.deleteConfirmDesc')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="hl-profiles">
|
||||
<div className="hl-profiles-head">
|
||||
<Tooltip title={t('settings.highlight.perHostHint')}>
|
||||
<span className="hl-master">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={perHost}
|
||||
onChange={(checked) => void updateTerminal({ highlightPerHost: checked })}
|
||||
/>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.perHost')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button size="small" onClick={() => setEditingId('')}>
|
||||
{t('settings.highlight.profileNew')}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="hl-editor-hint">{t('settings.highlight.profilesHint')}</span>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={profiles}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('settings.highlight.profileEmpty') }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={editingId !== null}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onOk={() => void handleSave()}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={saving}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
title={editing ? t('settings.highlight.profileEdit') : t('settings.highlight.profileNew')}
|
||||
okButtonProps={{ disabled: name.trim() === '' }}
|
||||
>
|
||||
<div className="hl-editor">
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.profileName')}</span>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('settings.highlight.profileNamePlaceholder')}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.profileRules')}</span>
|
||||
<div className="hl-editor-control">
|
||||
<span className="hl-editor-hint">{t('settings.highlight.profileRulesHint')}</span>
|
||||
<Checkbox.Group
|
||||
className="hl-profile-rules"
|
||||
value={ruleIds}
|
||||
onChange={(values) => setRuleIds(values as string[])}
|
||||
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,33 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Segmented,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tooltip
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { t } from '@shared/i18n'
|
||||
import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings'
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_RULES,
|
||||
HIGHLIGHT_CATEGORIES,
|
||||
highlightModeOf,
|
||||
type HighlightCategory,
|
||||
type HighlightMode
|
||||
} from '@shared/settings'
|
||||
import type { HighlightRule } from '@shared/settings'
|
||||
import { useSettingsStore } from './store'
|
||||
import { useResolvedTheme, useSettingsStore } from './store'
|
||||
import { HighlightImportExport } from './HighlightImportExport'
|
||||
import { HighlightProfiles } from './HighlightProfiles'
|
||||
import { ruleNote } from './ruleLabel'
|
||||
import { compileRules, previewSpans } from '../terminal/highlightEngine'
|
||||
import {
|
||||
getHighlightStats,
|
||||
resetHighlightStats,
|
||||
subscribeHighlightStats
|
||||
} from '../terminal/highlightStats'
|
||||
import { applyThemeColors } from '../theme/highlightColors'
|
||||
import './highlight.css'
|
||||
|
||||
const ORDERED_SORT: 'ascend' = 'ascend'
|
||||
@@ -22,18 +40,35 @@ const ORDERED_SORT: 'ascend' = 'ascend'
|
||||
/** preset color swatches for the highlighting editor. */
|
||||
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
|
||||
|
||||
/** Sample line for the editor preview — one line that exercises several presets. */
|
||||
const SAMPLE_TEXT = 'SUCCESS 42 passed 85% HTTP/1.1 404 exit code 1 deleted 3 files 120ms rm -rf build'
|
||||
|
||||
/**
|
||||
* Longest slice of the test text that gets previewed. A 200 KB paste would
|
||||
* produce thousands of spans, and React re-renders every one of them per
|
||||
* keystroke — measured at ~12 ms of highlighting plus a janky paint.
|
||||
*/
|
||||
const PREVIEW_LIMIT = 2000
|
||||
|
||||
/** clone the builtin preset set to prevent reference pollution by mutations. */
|
||||
const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[]
|
||||
|
||||
/**
|
||||
* Built-in rule notes are stored as zh-CN presets; the dictionaries carry a
|
||||
* `settings.highlight.builtin.<id>` entry per built-in id. `t()` returns the key
|
||||
* itself when it is missing, so fall back to the stored note in that case.
|
||||
*/
|
||||
function ruleNote(rule: HighlightRule): string | undefined {
|
||||
const key = `settings.highlight.builtin.${rule.id}`
|
||||
const translated = t(key)
|
||||
return translated === key ? rule.note : translated
|
||||
/** Compact duration for the stats column: µs below a millisecond, then ms. */
|
||||
function formatMs(ms: number): string {
|
||||
if (ms <= 0) return '0'
|
||||
return ms < 1 ? `${Math.round(ms * 1000)}µs` : `${ms.toFixed(1)}ms`
|
||||
}
|
||||
|
||||
/** Localised label for a rule's category, with a fallback for uncategorised rules. */
|
||||
function categoryLabel(category: HighlightCategory | undefined): string {
|
||||
return t(`settings.highlight.category.${category ?? 'none'}`)
|
||||
}
|
||||
|
||||
/** Sort rank for the grouped view: known categories in fixed order, none last. */
|
||||
function categoryRank(rule: HighlightRule): number {
|
||||
if (rule.category === undefined) return HIGHLIGHT_CATEGORIES.length
|
||||
const index = HIGHLIGHT_CATEGORIES.indexOf(rule.category)
|
||||
return index < 0 ? HIGHLIGHT_CATEGORIES.length : index
|
||||
}
|
||||
|
||||
function tryCompile(pattern: string): { ok: boolean; message?: string } {
|
||||
@@ -52,6 +87,9 @@ interface RuleDraft {
|
||||
priority: number
|
||||
fg: string
|
||||
bg?: string
|
||||
bands?: { min: number; fg: string }[]
|
||||
category?: HighlightCategory
|
||||
caseInsensitive: boolean
|
||||
note?: string
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -65,12 +103,20 @@ interface EditorState {
|
||||
export function HighlightTab(): React.JSX.Element {
|
||||
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
|
||||
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
|
||||
const highlightMode = useSettingsStore((s) => highlightModeOf(s.settings.terminal.highlightMode))
|
||||
const groupByCategory = useSettingsStore((s) => s.settings.terminal.highlightGroupByCategory)
|
||||
const statsOn = useSettingsStore((s) => s.settings.terminal.highlightStats)
|
||||
const themeColorsOn = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
|
||||
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
|
||||
const [editor, setEditor] = useState<EditorState>({ open: false })
|
||||
const [ioOpen, setIoOpen] = useState(false)
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...highlightRules].sort((a, b) => a.priority - b.priority),
|
||||
[highlightRules]
|
||||
)
|
||||
const sorted = useMemo(() => {
|
||||
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
|
||||
// Grouped view: cluster by category, keeping priority order inside a group
|
||||
// (Array.prototype.sort is stable, so the priority sort above survives).
|
||||
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
|
||||
}, [highlightRules, groupByCategory])
|
||||
|
||||
const openCreate = (): void => setEditor({ open: true, id: undefined })
|
||||
const openEdit = (target: HighlightRule): void => setEditor({ open: true, id: target.id })
|
||||
@@ -93,7 +139,52 @@ export function HighlightTab(): React.JSX.Element {
|
||||
await setHighlightRules(cloneDefaults())
|
||||
}
|
||||
|
||||
/** Only present in the grouped view, where it also explains the row order. */
|
||||
const categoryColumn: ColumnsType<HighlightRule>[number] = {
|
||||
title: t('settings.highlight.category'),
|
||||
dataIndex: 'category',
|
||||
width: 96,
|
||||
align: 'center',
|
||||
filters: [
|
||||
...HIGHLIGHT_CATEGORIES.map((category) => ({
|
||||
text: t(`settings.highlight.category.${category}`),
|
||||
value: category
|
||||
})),
|
||||
{ text: t('settings.highlight.category.none'), value: 'none' }
|
||||
],
|
||||
onFilter: (value, record) => (record.category ?? 'none') === value,
|
||||
render: (_category, record) => <span className="hl-cell-note">{categoryLabel(record.category)}</span>
|
||||
}
|
||||
|
||||
const [statsTick, setStatsTick] = useState(0)
|
||||
useEffect(() => subscribeHighlightStats(() => setStatsTick((n) => n + 1)), [])
|
||||
const ruleStats = useMemo(() => getHighlightStats().rules, [statsTick])
|
||||
|
||||
/** Only present while the stats switch is on. */
|
||||
const hitsColumn: ColumnsType<HighlightRule>[number] = {
|
||||
title: t('settings.highlight.statsHits'),
|
||||
key: 'hits',
|
||||
width: 76,
|
||||
align: 'center',
|
||||
sorter: (a, b) => (ruleStats.get(a.id)?.hits ?? 0) - (ruleStats.get(b.id)?.hits ?? 0),
|
||||
render: (_value, record) => (
|
||||
<span className="hl-cell-priority">{ruleStats.get(record.id)?.hits ?? 0}</span>
|
||||
)
|
||||
}
|
||||
const msColumn: ColumnsType<HighlightRule>[number] = {
|
||||
title: t('settings.highlight.statsMs'),
|
||||
key: 'ms',
|
||||
width: 88,
|
||||
align: 'center',
|
||||
sorter: (a, b) => (ruleStats.get(a.id)?.ms ?? 0) - (ruleStats.get(b.id)?.ms ?? 0),
|
||||
render: (_value, record) => (
|
||||
<span className="hl-cell-priority">{formatMs(ruleStats.get(record.id)?.ms ?? 0)}</span>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<HighlightRule> = [
|
||||
...(groupByCategory ? [categoryColumn] : []),
|
||||
...(statsOn ? [hitsColumn, msColumn] : []),
|
||||
{
|
||||
title: t('settings.highlight.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
@@ -107,7 +198,7 @@ export function HighlightTab(): React.JSX.Element {
|
||||
title: t('settings.highlight.pattern'),
|
||||
dataIndex: 'pattern',
|
||||
ellipsis: true,
|
||||
render: (pattern: string) => <PatternCell pattern={pattern} />
|
||||
render: (_pattern: string, record) => <PatternCell rule={record} />
|
||||
},
|
||||
{
|
||||
title: t('settings.highlight.priority'),
|
||||
@@ -121,19 +212,30 @@ export function HighlightTab(): React.JSX.Element {
|
||||
{
|
||||
title: t('settings.preview'),
|
||||
dataIndex: 'color',
|
||||
width: 120,
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render: (color: HighlightRule['color']) => (
|
||||
<span
|
||||
className="hl-preview-text"
|
||||
style={{
|
||||
color: color.fg,
|
||||
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
|
||||
}}
|
||||
>
|
||||
Highlight
|
||||
</span>
|
||||
)
|
||||
render: (color: HighlightRule['color'], record) =>
|
||||
record.bands && record.bands.length > 0 ? (
|
||||
<div className="hl-band-preview">
|
||||
{record.bands.map((band) => (
|
||||
<Tooltip key={band.min} title={`≥ ${band.min}`}>
|
||||
<span className="hl-preview-text" style={{ color: band.fg }}>
|
||||
≥{band.min}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="hl-preview-text"
|
||||
style={{
|
||||
color: color.fg,
|
||||
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
|
||||
}}
|
||||
>
|
||||
Highlight
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.highlight.note'),
|
||||
@@ -180,6 +282,57 @@ export function HighlightTab(): React.JSX.Element {
|
||||
message={t('settings.highlight.alert')}
|
||||
/>
|
||||
<div className="hl-toolbar">
|
||||
<Tooltip title={t('settings.highlight.modeDesc')}>
|
||||
<span className="hl-master">
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.mode')}</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={highlightMode}
|
||||
onChange={(value) => void updateTerminal({ highlightMode: value as HighlightMode })}
|
||||
options={[
|
||||
{ value: 'all', label: t('settings.highlight.modeAll') },
|
||||
{ value: 'basic', label: t('settings.highlight.modeBasic') },
|
||||
{ value: 'off', label: t('settings.highlight.modeOff') }
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.highlight.groupByCategory')}>
|
||||
<span className="hl-master">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={groupByCategory}
|
||||
onChange={(checked) => void updateTerminal({ highlightGroupByCategory: checked })}
|
||||
/>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.groupByCategory')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.highlight.statsHint')}>
|
||||
<span className="hl-master">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={statsOn}
|
||||
onChange={(checked) => void updateTerminal({ highlightStats: checked })}
|
||||
/>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.stats')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{statsOn && (
|
||||
<Button size="small" onClick={() => resetHighlightStats()}>
|
||||
{t('settings.highlight.statsReset')}
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title={t('settings.highlight.themeColorsHint')}>
|
||||
<span className="hl-master">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={themeColorsOn}
|
||||
onChange={(checked) => void updateTerminal({ highlightThemeColors: checked })}
|
||||
/>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.themeColors')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button onClick={() => setIoOpen(true)}>{t('settings.highlight.importExport')}</Button>
|
||||
<Popconfirm
|
||||
title={t('settings.highlight.resetTitle')}
|
||||
description={t('settings.highlight.resetDesc')}
|
||||
@@ -193,7 +346,9 @@ export function HighlightTab(): React.JSX.Element {
|
||||
{t('settings.highlight.addRule')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hl-table">
|
||||
<HighlightImportExport open={ioOpen} onClose={() => setIoOpen(false)} />
|
||||
<HighlightProfiles />
|
||||
<div className={highlightMode === 'off' ? 'hl-table hl-table-off' : 'hl-table'}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
@@ -212,12 +367,17 @@ export function HighlightTab(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function PatternCell({ pattern }: { pattern: string }): React.JSX.Element {
|
||||
const { ok, message } = tryCompile(pattern)
|
||||
function PatternCell({ rule }: { rule: HighlightRule }): React.JSX.Element {
|
||||
const { ok, message } = tryCompile(rule.pattern)
|
||||
return (
|
||||
<div className="hl-pattern-cell">
|
||||
<Tooltip title={pattern}>
|
||||
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{pattern}</span>
|
||||
{rule.caseInsensitive === true && (
|
||||
<Tooltip title={t('settings.highlight.caseInsensitive')}>
|
||||
<span className="hl-case-badge">Aa</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={rule.pattern}>
|
||||
<span className={ok ? 'hl-pattern-text' : 'hl-pattern-text hl-pattern-bad'}>{rule.pattern}</span>
|
||||
</Tooltip>
|
||||
{!ok && (
|
||||
<Tooltip title={message}>
|
||||
@@ -243,6 +403,8 @@ function HighlightEditor({
|
||||
}): React.JSX.Element {
|
||||
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
|
||||
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
|
||||
const themeColors = useSettingsStore((s) => s.settings.terminal.highlightThemeColors)
|
||||
const theme = useResolvedTheme()
|
||||
|
||||
const isCreate = ruleId === undefined
|
||||
const editing = useMemo(
|
||||
@@ -255,11 +417,55 @@ function HighlightEditor({
|
||||
priority: 1,
|
||||
fg: '#3fb950',
|
||||
bg: undefined,
|
||||
caseInsensitive: false,
|
||||
note: undefined,
|
||||
enabled: true
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showBg, setShowBg] = useState(false)
|
||||
const [testText, setTestText] = useState('')
|
||||
|
||||
/**
|
||||
* Preview the draft rule *together with* the other rules: a span that some
|
||||
* earlier rule steals (`done`, claimed by the shell-keyword rule) then shows up
|
||||
* here rather than surprising the user in the terminal.
|
||||
*/
|
||||
const previewRules = useMemo(() => {
|
||||
const draftRule: HighlightRule = {
|
||||
id: ruleId ?? 'draft',
|
||||
pattern: draft.pattern,
|
||||
enabled: true,
|
||||
priority: draft.priority,
|
||||
color: { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) },
|
||||
...(draft.bands && draft.bands.length > 0 ? { bands: draft.bands } : {}),
|
||||
...(draft.caseInsensitive ? { caseInsensitive: true } : {})
|
||||
}
|
||||
const active = [...highlightRules.filter((rule) => rule.id !== ruleId), draftRule]
|
||||
// The preview has to show the colours the terminal will use, so the theme
|
||||
// mapping is applied here exactly as TerminalView applies it.
|
||||
return compileRules(themeColors ? applyThemeColors(active, theme) : active)
|
||||
}, [
|
||||
draft.pattern,
|
||||
draft.priority,
|
||||
draft.fg,
|
||||
draft.bg,
|
||||
draft.bands,
|
||||
draft.caseInsensitive,
|
||||
showBg,
|
||||
highlightRules,
|
||||
ruleId,
|
||||
themeColors,
|
||||
theme
|
||||
])
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
previewSpans(
|
||||
(testText.trim() === '' ? SAMPLE_TEXT : testText).slice(0, PREVIEW_LIMIT),
|
||||
previewRules
|
||||
),
|
||||
[testText, previewRules]
|
||||
)
|
||||
|
||||
// (re)initialize the form each time the modal opens
|
||||
useEffect(() => {
|
||||
@@ -274,7 +480,10 @@ function HighlightEditor({
|
||||
const bg = editing ? editing.color.bg : undefined
|
||||
const note = editing ? editing.note : undefined
|
||||
const enabled = editing ? editing.enabled : true
|
||||
setDraft({ pattern, priority, fg, bg, note, enabled })
|
||||
const caseInsensitive = editing ? editing.caseInsensitive === true : false
|
||||
const bands = editing?.bands
|
||||
const category = editing?.category
|
||||
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
|
||||
setShowBg(bg != null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
@@ -284,11 +493,34 @@ function HighlightEditor({
|
||||
|
||||
const patch = (partial: Partial<RuleDraft>): void => setDraft((prev) => ({ ...prev, ...partial }))
|
||||
|
||||
const addBand = (): void => {
|
||||
const bands = draft.bands ?? []
|
||||
const top = bands[bands.length - 1]
|
||||
// the new row starts above the current top one, so it is usable as-is
|
||||
patch({ bands: [...bands, { min: top ? top.min + 20 : 0, fg: PRESET_COLORS[0] }] })
|
||||
}
|
||||
|
||||
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
|
||||
const bands = draft.bands
|
||||
if (!bands) return
|
||||
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
|
||||
}
|
||||
|
||||
const removeBand = (index: number): void => {
|
||||
const bands = draft.bands
|
||||
if (!bands) return
|
||||
const next = bands.filter((_, i) => i !== index)
|
||||
// dropping the last band switches the rule back to a single colour
|
||||
patch({ bands: next.length > 0 ? next : undefined })
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) }
|
||||
const caseInsensitive = draft.caseInsensitive ? true : undefined
|
||||
const bands = draft.bands && draft.bands.length > 0 ? draft.bands : undefined
|
||||
if (isCreate) {
|
||||
const rule: HighlightRule = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -296,6 +528,9 @@ function HighlightEditor({
|
||||
priority: draft.priority,
|
||||
enabled: draft.enabled,
|
||||
color,
|
||||
bands,
|
||||
category: draft.category,
|
||||
caseInsensitive,
|
||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||
}
|
||||
await setHighlightRules([...highlightRules, rule])
|
||||
@@ -306,6 +541,9 @@ function HighlightEditor({
|
||||
priority: draft.priority,
|
||||
enabled: draft.enabled,
|
||||
color,
|
||||
bands,
|
||||
category: draft.category,
|
||||
caseInsensitive,
|
||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||
}
|
||||
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
|
||||
@@ -343,6 +581,35 @@ function HighlightEditor({
|
||||
{!compile.ok && compile.message != null && (
|
||||
<div className="hl-editor-error">{compile.message}</div>
|
||||
)}
|
||||
<div className="hl-editor-inline">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={draft.caseInsensitive}
|
||||
onChange={(c) => patch({ caseInsensitive: c })}
|
||||
/>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.caseInsensitive')}</span>
|
||||
<span className="hl-editor-hint">{t('settings.highlight.caseInsensitiveHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.testText')}</span>
|
||||
<div className="hl-editor-control">
|
||||
<Input.TextArea
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder={SAMPLE_TEXT}
|
||||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||
/>
|
||||
<div className="hl-preview-line">
|
||||
{preview.map((span, index) => (
|
||||
<span key={index} style={{ color: span.fg, background: span.bg }}>
|
||||
{span.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="hl-editor-hint">{t('settings.highlight.testHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,6 +655,48 @@ function HighlightEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.bands')}</span>
|
||||
<div className="hl-editor-control">
|
||||
<div className="hl-editor-inline">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={draft.bands != null}
|
||||
onChange={(c) =>
|
||||
patch({ bands: c ? (draft.bands ?? [{ min: 0, fg: draft.fg }]) : undefined })
|
||||
}
|
||||
/>
|
||||
<span className="hl-editor-hint">{t('settings.highlight.bandsHint')}</span>
|
||||
</div>
|
||||
{draft.bands?.map((band, index) => (
|
||||
<div className="hl-editor-inline" key={index}>
|
||||
<span className="hl-editor-sub-label">{t('settings.highlight.bandMin')}</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={band.min}
|
||||
onChange={(v) => updateBand(index, { min: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: 88 }}
|
||||
/>
|
||||
<ColorField value={band.fg} onChange={(v) => updateBand(index, { fg: v })} />
|
||||
<Button
|
||||
size="small"
|
||||
title={t('settings.highlight.removeBand')}
|
||||
onClick={() => removeBand(index)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{draft.bands != null && (
|
||||
<div className="hl-editor-inline">
|
||||
<Button size="small" onClick={addBand}>
|
||||
{t('settings.highlight.addBand')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.palette')}</span>
|
||||
<div className="hl-palette">
|
||||
@@ -413,6 +722,22 @@ function HighlightEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.category')}</span>
|
||||
<Select
|
||||
size="small"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={draft.category}
|
||||
placeholder={t('settings.highlight.category.none')}
|
||||
onChange={(value) => patch({ category: value ?? undefined })}
|
||||
options={HIGHLIGHT_CATEGORIES.map((category) => ({
|
||||
value: category,
|
||||
label: t(`settings.highlight.category.${category}`)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
|
||||
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
|
||||
|
||||
@@ -69,6 +69,19 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* marks a rule that matches without regard to letter case */
|
||||
.hl-case-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
|
||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ---- misc cells ---- */
|
||||
|
||||
.hl-cell-priority {
|
||||
@@ -96,6 +109,110 @@
|
||||
min-width: 74px;
|
||||
}
|
||||
|
||||
/* master switch sits on the left of the toolbar */
|
||||
.hl-master {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* rules are kept while highlighting is off — say so visually */
|
||||
.hl-table-off {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* live preview line under the test-text box */
|
||||
.hl-preview-line {
|
||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 24px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
|
||||
}
|
||||
|
||||
/* ---- per-host rule sets ---- */
|
||||
|
||||
.hl-profiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hl-profiles-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* the rule picker can hold 20+ entries; keep the dialog from growing */
|
||||
.hl-profile-rules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.hl-profile-rules .ant-checkbox-wrapper {
|
||||
align-items: flex-start;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- import / export dialog ---- */
|
||||
|
||||
.hl-io {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.hl-io-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hl-io-label {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
|
||||
}
|
||||
|
||||
.hl-io-json textarea {
|
||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* the file input is driven by the button next to it */
|
||||
.hl-io-file input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- value-banded rule: one chip per threshold, in that band's colour ---- */
|
||||
.hl-band-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
}
|
||||
|
||||
.hl-band-preview .hl-preview-text {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hl-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { t } from '@shared/i18n'
|
||||
import type { HighlightRule } from '@shared/settings'
|
||||
|
||||
/**
|
||||
* Labels for a rule in the settings UI.
|
||||
*
|
||||
* Built-in rules store a zh-CN note and carry a `settings.highlight.builtin.<id>`
|
||||
* translation; `t()` returns the key itself when a translation is missing, which
|
||||
* is exactly how the two are told apart here.
|
||||
*/
|
||||
|
||||
/** Translated note for a built-in rule, or the stored note for a user rule. */
|
||||
export function ruleNote(rule: HighlightRule): string | undefined {
|
||||
const key = `settings.highlight.builtin.${rule.id}`
|
||||
const translated = t(key)
|
||||
if (translated !== key) return translated
|
||||
return rule.note !== undefined && rule.note !== '' ? rule.note : undefined
|
||||
}
|
||||
|
||||
/** Label for a rule in a picker: its note, falling back to the pattern itself. */
|
||||
export function ruleLabel(rule: HighlightRule): string {
|
||||
return ruleNote(rule) ?? rule.pattern
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export interface SettingsState {
|
||||
setCustomThemes: (themes: AppSettings['customThemes']) => Promise<void>
|
||||
/** replace highlightRules array and persist */
|
||||
setHighlightRules: (rules: AppSettings['highlightRules']) => Promise<void>
|
||||
/** replace highlightProfiles array and persist */
|
||||
setHighlightProfiles: (profiles: AppSettings['highlightProfiles']) => Promise<void>
|
||||
/** shallow-merge into settings.system and persist */
|
||||
updateSystem: (partial: Partial<SystemSettings>) => Promise<void>
|
||||
}
|
||||
@@ -56,6 +58,9 @@ async function persist(
|
||||
const patch: Partial<AppSettings> = {}
|
||||
if (written.customThemes !== prev.customThemes) patch.customThemes = written.customThemes
|
||||
if (written.highlightRules !== prev.highlightRules) patch.highlightRules = written.highlightRules
|
||||
if (written.highlightProfiles !== prev.highlightProfiles) {
|
||||
patch.highlightProfiles = written.highlightProfiles
|
||||
}
|
||||
const terminal = diffGroup(prev.terminal, written.terminal)
|
||||
// Main merges each group one level deep, so a group holding only the
|
||||
// changed sub-keys is a valid patch payload even though the contract types
|
||||
@@ -116,6 +121,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
await persist(get, set, next, prev, 'setHighlightRules')
|
||||
},
|
||||
|
||||
setHighlightProfiles: async (profiles) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, highlightProfiles: profiles }
|
||||
await persist(get, set, next, prev, 'setHighlightProfiles')
|
||||
},
|
||||
|
||||
updateSystem: async (partial) => {
|
||||
const prev = get().settings
|
||||
const next: AppSettings = { ...prev, system: { ...prev.system, ...partial } }
|
||||
|
||||
@@ -11,10 +11,13 @@ import { Checkbox, Modal } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
|
||||
import { rulesForProfile } from '@shared/highlightProfiles'
|
||||
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
|
||||
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
|
||||
import { compileRules, HighlightStream } from './highlightEngine'
|
||||
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
|
||||
import { publishHighlightStats } from './highlightStats'
|
||||
import { applyThemeColors } from '@renderer/theme/highlightColors'
|
||||
import { findUrls } from './urlLinks'
|
||||
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
||||
import { cdArgument, conemuCwd } from './cwdTracker'
|
||||
@@ -98,6 +101,8 @@ export interface TerminalViewProps {
|
||||
className?: string
|
||||
/** ssh sessions have a remote cwd — the open-directory button is hidden */
|
||||
isSsh?: boolean
|
||||
/** saved connection behind an ssh session, used to look up its highlight profile */
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
interface SearchBarProps {
|
||||
@@ -256,7 +261,10 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
|
||||
}
|
||||
|
||||
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
|
||||
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className, isSsh = false }, ref) {
|
||||
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner(
|
||||
{ sessionId, onClose, className, isSsh = false, connectionId },
|
||||
ref
|
||||
) {
|
||||
const settings = useSettingsStore((s) => s.settings)
|
||||
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -301,7 +309,75 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// the (stable, single-construction) pty/term subscriptions always read the
|
||||
// latest rules without triggering a terminal rebuild. Only new output is
|
||||
// highlighted — the existing scrollback buffer is left untouched.
|
||||
const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules])
|
||||
//
|
||||
// The mode empties the rule set instead of bypassing the stream: the stream
|
||||
// holds a trailing text run back, and skipping it would drop those bytes. An
|
||||
// empty set is ~free — applyHighlights returns the chunk before it tokenizes.
|
||||
const highlightMode = highlightModeOf(settings.terminal.highlightMode)
|
||||
const highlightTheme = useResolvedTheme()
|
||||
// Per-host profiles (opt-in): an SSH session bound to a profile runs only that
|
||||
// subset. The binding lives on the saved connection, so it is looked up once
|
||||
// per session — and only while the feature is switched on.
|
||||
const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (connectionId === undefined || !settings.terminal.highlightPerHost) {
|
||||
setBoundProfileId(undefined)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
void window.api
|
||||
.listConnections()
|
||||
.then((list) => {
|
||||
if (!cancelled) {
|
||||
setBoundProfileId(list.find((conn) => conn.id === connectionId)?.highlightProfileId)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [connectionId, settings.terminal.highlightPerHost])
|
||||
|
||||
const compiledRules = useMemo(() => {
|
||||
if (highlightMode === 'off') return []
|
||||
const active =
|
||||
highlightMode === 'basic'
|
||||
? settings.highlightRules.filter((rule) => rule.basic === true)
|
||||
: settings.highlightRules
|
||||
const perHost = settings.terminal.highlightPerHost
|
||||
? rulesForProfile(active, boundProfileId, settings.highlightProfiles)
|
||||
: active
|
||||
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
|
||||
// colour comes from the theme the terminal is actually drawn with.
|
||||
return compileRules(
|
||||
settings.terminal.highlightThemeColors ? applyThemeColors(perHost, highlightTheme) : perHost
|
||||
)
|
||||
}, [
|
||||
highlightMode,
|
||||
settings.highlightRules,
|
||||
settings.highlightProfiles,
|
||||
settings.terminal.highlightThemeColors,
|
||||
settings.terminal.highlightPerHost,
|
||||
boundProfileId,
|
||||
highlightTheme
|
||||
])
|
||||
|
||||
// Optional per-rule stats (settings → highlight → "count hits and time").
|
||||
// Attached and detached here, so toggling the switch never rebuilds the
|
||||
// terminal, and published on a timer — a busy terminal would otherwise
|
||||
// re-render the settings page once per chunk.
|
||||
const statsRef = useRef<Map<string, RuleStat> | null>(null)
|
||||
useEffect(() => {
|
||||
const sink = settings.terminal.highlightStats
|
||||
? (statsRef.current ?? new Map<string, RuleStat>())
|
||||
: null
|
||||
statsRef.current = sink
|
||||
streamRef.current?.setStats(sink ?? undefined)
|
||||
if (sink === null) return undefined
|
||||
publishHighlightStats(new Map(sink))
|
||||
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [settings.terminal.highlightStats])
|
||||
streamRef.current?.setRules(compiledRules)
|
||||
|
||||
// M5: load completion candidates once per session (history + library cached).
|
||||
@@ -767,6 +843,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// across a sessionId rebind (e.g. template apply).
|
||||
const stream = new HighlightStream(compiledRules)
|
||||
streamRef.current = stream
|
||||
// A session created while stats are on keeps collecting into the same map.
|
||||
stream.setStats(statsRef.current ?? undefined)
|
||||
// The stream holds a trailing text run for cross-chunk anchor consistency.
|
||||
// A shell prompt is exactly such a run with no follow-up data, so flush the
|
||||
// carry on a short timer — prompts render within a frame, anchors survive.
|
||||
|
||||
@@ -12,16 +12,32 @@ import type { HighlightRule } from '@shared/settings'
|
||||
|
||||
export interface CompiledRule {
|
||||
id: string
|
||||
/** compiled with the 'g' flag, pattern's own flags otherwise untouched */
|
||||
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
|
||||
regex: RegExp
|
||||
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
|
||||
fg: string
|
||||
/** optional hex bg */
|
||||
bg?: string
|
||||
/** optional value bands, ascending by `min` — see HighlightRule.bands */
|
||||
bands?: { min: number; fg: string }[]
|
||||
/** copied from the source rule for stable priority ordering */
|
||||
priority: number
|
||||
}
|
||||
|
||||
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
|
||||
export interface RuleStat {
|
||||
/** matches that were actually coloured */
|
||||
hits: number
|
||||
/** wall time spent scanning with this rule, milliseconds */
|
||||
ms: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional sink for the settings page's stats view. Absent on the default path,
|
||||
* so an ordinary terminal write pays nothing for the counters.
|
||||
*/
|
||||
export type StatsSink = Map<string, RuleStat>
|
||||
|
||||
type Token = { kind: 'seq' | 'text'; value: string }
|
||||
type Span = { start: number; end: number; fg: string; bg?: string }
|
||||
|
||||
@@ -84,20 +100,36 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
|
||||
if (typeof pattern !== 'string' || pattern.length === 0) continue
|
||||
let regex: RegExp
|
||||
try {
|
||||
// 'g' always; case-sensitivity left to the pattern (no automatic 'i').
|
||||
regex = new RegExp(pattern, 'g')
|
||||
// 'g' always; 'i' when the rule asks for case-insensitive matching.
|
||||
// Lookbehinds (used by the preset success rule to leave `not ok` alone)
|
||||
// need no special handling — they are plain V8 regex syntax.
|
||||
regex = new RegExp(pattern, rule.caseInsensitive === true ? 'gi' : 'g')
|
||||
} catch {
|
||||
continue // invalid pattern → silently skip
|
||||
}
|
||||
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
|
||||
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
|
||||
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
|
||||
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority })
|
||||
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
|
||||
}
|
||||
out.sort((a, b) => a.priority - b.priority)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
|
||||
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
|
||||
if (!Array.isArray(bands)) return undefined
|
||||
const kept: { min: number; fg: string }[] = []
|
||||
for (const band of bands) {
|
||||
if (!band || !Number.isFinite(band.min)) continue
|
||||
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
|
||||
kept.push({ min: band.min, fg: band.fg })
|
||||
}
|
||||
if (kept.length === 0) return undefined
|
||||
kept.sort((a, b) => a.min - b.min)
|
||||
return kept
|
||||
}
|
||||
|
||||
/** Wrap one plain-text span with truecolor SGR. */
|
||||
function wrap(text: string, fgHex: string, bgHex?: string): string {
|
||||
const f = hexToRgb(fgHex)
|
||||
@@ -117,6 +149,26 @@ function overlaps(claimed: Span[], s: number, e: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour for one match: with value bands the match's first number picks the
|
||||
* band (last `min` that is <= the value wins); everything else keeps the rule's
|
||||
* own foreground.
|
||||
*/
|
||||
function bandColor(rule: CompiledRule, matched: string): string {
|
||||
const bands = rule.bands
|
||||
if (!bands || bands.length === 0) return rule.fg
|
||||
const number = /-?\d+(?:\.\d+)?/.exec(matched)
|
||||
if (number === null) return rule.fg
|
||||
const value = Number(number[0])
|
||||
if (!Number.isFinite(value)) return rule.fg
|
||||
let fg = rule.fg
|
||||
for (const band of bands) {
|
||||
if (value < band.min) break
|
||||
fg = band.fg
|
||||
}
|
||||
return fg
|
||||
}
|
||||
|
||||
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
|
||||
function hasLongLine(text: string): boolean {
|
||||
let start = 0
|
||||
@@ -134,13 +186,17 @@ function hasLongLine(text: string): boolean {
|
||||
* SGR-injected text. With a global budget of zero for every rule the run is
|
||||
* returned untouched, as is a run holding an over-long line (see MAX_LINE_LEN).
|
||||
*/
|
||||
function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string {
|
||||
/** Millisecond clock for the optional stats sink; `performance` when present. */
|
||||
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
|
||||
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink): string {
|
||||
if (text.length === 0) return ''
|
||||
if (hasLongLine(text)) return text
|
||||
const claimed: Span[] = []
|
||||
for (let ri = 0; ri < rules.length; ri++) {
|
||||
const rule = rules[ri]
|
||||
if (budgets[ri] <= 0) continue
|
||||
const started = stats === undefined ? 0 : now()
|
||||
rule.regex.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
let made = 0
|
||||
@@ -153,13 +209,19 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
|
||||
continue
|
||||
}
|
||||
if (!overlaps(claimed, start, end)) {
|
||||
claimed.push({ start, end, fg: rule.fg, bg: rule.bg })
|
||||
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
|
||||
made++
|
||||
budgets[ri]--
|
||||
if (budgets[ri] <= 0) break
|
||||
}
|
||||
if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse
|
||||
}
|
||||
if (stats !== undefined) {
|
||||
const stat = stats.get(rule.id) ?? { hits: 0, ms: 0 }
|
||||
stat.hits += made
|
||||
stat.ms += now() - started
|
||||
stats.set(rule.id, stat)
|
||||
}
|
||||
}
|
||||
if (claimed.length === 0) return text
|
||||
claimed.sort((a, b) => a.start - b.start)
|
||||
@@ -177,8 +239,10 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[]): strin
|
||||
/**
|
||||
* Rewrite `chunk` with truecolor highlights. Returns the input unchanged when
|
||||
* there are no enabled rules, the chunk is empty, or it exceeds the budget.
|
||||
* With a `stats` sink attached, per-rule hit counts and scan times are added to
|
||||
* it — the settings page's stats view is the only caller that passes one.
|
||||
*/
|
||||
export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
|
||||
export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: StatsSink): string {
|
||||
if (!chunk || rules.length === 0) return chunk
|
||||
if (chunk.length > MAX_CHUNK) return chunk
|
||||
|
||||
@@ -197,15 +261,54 @@ export function applyHighlights(chunk: string, rules: CompiledRule[]): string {
|
||||
continue
|
||||
}
|
||||
const before = budgets.reduce((a, b) => a + b, 0)
|
||||
out += applyRun(token.value, rules, budgets)
|
||||
out += applyRun(token.value, rules, budgets, stats)
|
||||
const after = budgets.reduce((a, b) => a + b, 0)
|
||||
if (after < before) usedBudget = true
|
||||
}
|
||||
return usedBudget ? out : chunk
|
||||
}
|
||||
|
||||
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */
|
||||
function isIncompleteEscape(seg: string): boolean {
|
||||
/** One coloured or plain segment of a preview line. */
|
||||
export interface PreviewSpan {
|
||||
text: string
|
||||
fg?: string
|
||||
bg?: string
|
||||
}
|
||||
|
||||
/** "#rrggbb" from three decimal SGR colour components. */
|
||||
function toHex(r: string, g: string, b: string): string {
|
||||
const part = (value: string): string => Number(value).toString(16).padStart(2, '0')
|
||||
return `#${part(r)}${part(g)}${part(b)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `text` into coloured and plain segments for the settings preview.
|
||||
*
|
||||
* It runs the real highlighter and parses its own SGR output instead of
|
||||
* re-implementing the matching, so the editor preview cannot drift from what the
|
||||
* terminal actually receives. Escape sequences already in the sample are
|
||||
* stripped first: this shows text the user typed, not a captured stream.
|
||||
*/
|
||||
export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[] {
|
||||
const out = applyHighlights(text.replace(ESCAPE_RE, ''), rules)
|
||||
const spans: PreviewSpan[] = []
|
||||
const SGR = /\x1b\[38;2;(\d+);(\d+);(\d+)(?:;48;2;(\d+);(\d+);(\d+))?m([\s\S]*?)\x1b\[0m/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = SGR.exec(out)) !== null) {
|
||||
if (m.index > last) spans.push({ text: out.slice(last, m.index) })
|
||||
spans.push({
|
||||
text: m[7],
|
||||
fg: toHex(m[1], m[2], m[3]),
|
||||
...(m[4] !== undefined ? { bg: toHex(m[4], m[5], m[6]) } : {})
|
||||
})
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < out.length) spans.push({ text: out.slice(last) })
|
||||
return spans
|
||||
}
|
||||
|
||||
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
|
||||
// `seg` is expected to start at (or be) a lone ESC.
|
||||
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
|
||||
if (seg === '\x1b') return true
|
||||
@@ -251,6 +354,7 @@ const MAX_CARRY = 1024
|
||||
*/
|
||||
export class HighlightStream {
|
||||
private rules: CompiledRule[]
|
||||
private stats?: StatsSink
|
||||
private buffer = ''
|
||||
|
||||
constructor(rules: CompiledRule[]) {
|
||||
@@ -262,6 +366,11 @@ export class HighlightStream {
|
||||
this.rules = rules
|
||||
}
|
||||
|
||||
/** Attach or drop the optional stats sink (see `highlightStats`). */
|
||||
setStats(stats: StatsSink | undefined): void {
|
||||
this.stats = stats
|
||||
}
|
||||
|
||||
/** True while content is held back (a pending flush will release it). */
|
||||
hasPending(): boolean {
|
||||
return this.buffer.length > 0
|
||||
@@ -280,10 +389,10 @@ export class HighlightStream {
|
||||
// Give up waiting on a pathologically long unterminated sequence: emit
|
||||
// the fragment raw (applyHighlights skips raw \x1b in text runs).
|
||||
this.buffer = ''
|
||||
return applyHighlights(head, this.rules) + tail
|
||||
return applyHighlights(head, this.rules, this.stats) + tail
|
||||
}
|
||||
this.buffer = tail
|
||||
return applyHighlights(head, this.rules)
|
||||
return applyHighlights(head, this.rules, this.stats)
|
||||
}
|
||||
|
||||
// (b) no incomplete escape -> find the last *closed* escape and emit only
|
||||
@@ -295,20 +404,20 @@ export class HighlightStream {
|
||||
if (this.buffer.length > MAX_CHUNK) {
|
||||
const raw = this.buffer
|
||||
this.buffer = ''
|
||||
return applyHighlights(raw, this.rules)
|
||||
return applyHighlights(raw, this.rules, this.stats)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const head = this.buffer.slice(0, lastEnd)
|
||||
this.buffer = this.buffer.slice(lastEnd)
|
||||
return applyHighlights(head, this.rules)
|
||||
return applyHighlights(head, this.rules, this.stats)
|
||||
}
|
||||
|
||||
/** Flush any held content (session end) — highlighted as a final run. */
|
||||
flush(): string {
|
||||
const b = this.buffer
|
||||
this.buffer = ''
|
||||
return applyHighlights(b, this.rules)
|
||||
return applyHighlights(b, this.rules, this.stats)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RuleStat } from './highlightEngine'
|
||||
|
||||
/**
|
||||
* Live per-rule highlight counters, read by the settings page.
|
||||
*
|
||||
* The engine fills these only when a sink is attached, and `TerminalView`
|
||||
* attaches one only while `settings.terminal.highlightStats` is on — so the
|
||||
* default write path pays nothing. Totals are cumulative since the last reset;
|
||||
* TerminalView publishes on a timer (not per chunk), because a busy terminal
|
||||
* would otherwise re-render the settings page thousands of times a second.
|
||||
*/
|
||||
|
||||
let totals: Map<string, RuleStat> = new Map()
|
||||
let updatedAt = 0
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export interface HighlightStatsSnapshot {
|
||||
rules: Map<string, RuleStat>
|
||||
/** epoch ms of the last publish; the UI re-renders on change */
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export function getHighlightStats(): HighlightStatsSnapshot {
|
||||
return { rules: totals, updatedAt }
|
||||
}
|
||||
|
||||
/** Hand the accumulated counters to the UI (TerminalView owns the map). */
|
||||
export function publishHighlightStats(next: Map<string, RuleStat>): void {
|
||||
totals = next
|
||||
updatedAt = Date.now()
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export function resetHighlightStats(): void {
|
||||
publishHighlightStats(new Map())
|
||||
}
|
||||
|
||||
export function subscribeHighlightStats(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { hexToRgb } from '../terminal/highlightEngine'
|
||||
import type { HighlightRule } from '@shared/settings'
|
||||
import type { TerminalTheme, ThemeColors } from '@shared/theme'
|
||||
|
||||
/**
|
||||
* Optional "follow the theme" colour mapping for highlight rules.
|
||||
*
|
||||
* Rules ship with hand-picked hex colours that read on any theme, but a user on
|
||||
* Solarized or Gruvbox usually wants highlighting drawn from *their* palette.
|
||||
* The mapping buckets a colour by hue instead of picking the nearest swatch, so
|
||||
* the intent survives: a success green stays a green, an error red stays a red.
|
||||
* Brightness selects the normal or bright palette entry, which is what keeps a
|
||||
* two-green value band (50–80% light green vs 80%+ green) distinguishable.
|
||||
*
|
||||
* Muted colours (grey-ish, like the 1xx status grey) are left untouched — there
|
||||
* is no hue to follow, and the palette has no neutral that means "quieter".
|
||||
*/
|
||||
|
||||
/** ANSI palette entries a highlight colour can land on. */
|
||||
type PaletteName = 'red' | 'yellow' | 'green' | 'cyan' | 'blue' | 'magenta'
|
||||
|
||||
export interface PaletteMatch {
|
||||
name: PaletteName
|
||||
bright: boolean
|
||||
}
|
||||
|
||||
/** Below this saturation a colour counts as neutral and keeps its own value. */
|
||||
const NEUTRAL_SATURATION = 0.15
|
||||
/** Above this lightness the "bright" palette entry is used. */
|
||||
const BRIGHT_LIGHTNESS = 0.6
|
||||
|
||||
/** Bucket a colour by hue/brightness, or `null` for neutrals. */
|
||||
export function paletteMatch(hex: string): PaletteMatch | null {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
if (max === 0 || (max - min) / max < NEUTRAL_SATURATION) return null
|
||||
|
||||
const lightness = (max + min) / 2 / 255
|
||||
const delta = max - min
|
||||
let hue: number
|
||||
if (max === r) hue = ((g - b) / delta) % 6
|
||||
else if (max === g) hue = (b - r) / delta + 2
|
||||
else hue = (r - g) / delta + 4
|
||||
hue = (hue * 60 + 360) % 360
|
||||
|
||||
const name: PaletteName =
|
||||
hue < 25 || hue >= 335
|
||||
? 'red'
|
||||
: hue < 70
|
||||
? 'yellow'
|
||||
: hue < 165
|
||||
? 'green'
|
||||
: hue < 200
|
||||
? 'cyan'
|
||||
: hue < 265
|
||||
? 'blue'
|
||||
: 'magenta'
|
||||
|
||||
return { name, bright: lightness >= BRIGHT_LIGHTNESS }
|
||||
}
|
||||
|
||||
/** The theme's colour for a rule colour, or the rule colour when it has no hue. */
|
||||
export function themeColorFor(hex: string, theme: TerminalTheme): string {
|
||||
const match = paletteMatch(hex)
|
||||
if (match === null) return hex
|
||||
const key = (
|
||||
match.bright ? `bright${match.name[0].toUpperCase()}${match.name.slice(1)}` : match.name
|
||||
) as keyof ThemeColors
|
||||
const color = theme.colors[key]
|
||||
return typeof color === 'string' && color !== '' ? color : hex
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a rule set onto the theme's palette. Backgrounds are left alone on
|
||||
* purpose: they are a block behind the text, not a semantic signal, and mapping
|
||||
* them to a palette *foreground* colour would invert their meaning.
|
||||
*/
|
||||
export function applyThemeColors(rules: HighlightRule[], theme: TerminalTheme): HighlightRule[] {
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
color: { fg: themeColorFor(rule.color.fg, theme), ...(rule.color.bg ? { bg: rule.color.bg } : {}) },
|
||||
...(rule.bands
|
||||
? { bands: rule.bands.map((band) => ({ ...band, fg: themeColorFor(band.fg, theme) })) }
|
||||
: {})
|
||||
}))
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
|
||||
<TerminalView
|
||||
sessionId={sessionId}
|
||||
isSsh={isSsh}
|
||||
connectionId={params.connectionId}
|
||||
className="workspace-terminal-view"
|
||||
onClose={() => {
|
||||
if (closedRef.current) return
|
||||
|
||||
@@ -55,6 +55,8 @@ interface TerminalParams {
|
||||
sessionId?: string
|
||||
sessionKind?: 'local' | 'ssh'
|
||||
hostLabel?: string
|
||||
/** saved SSH connection this session came from, for per-host highlight profiles */
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
export type { TerminalParams }
|
||||
@@ -744,7 +746,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: conn.name,
|
||||
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
|
||||
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host, connectionId: conn.id }
|
||||
})
|
||||
// B's sidebar refreshes lastConnectedAt on connect.
|
||||
void sidebarRef.current?.refresh()
|
||||
@@ -834,7 +836,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: reference.title,
|
||||
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel },
|
||||
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel, connectionId: refParams?.connectionId },
|
||||
position: { referencePanel: reference.id, direction }
|
||||
})
|
||||
},
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface SshConnection {
|
||||
createdAt: number
|
||||
lastConnectedAt?: number
|
||||
savedAuth: SshSavedAuthFlags
|
||||
/** highlight profile bound to this host; absent = run every rule */
|
||||
highlightProfileId?: string
|
||||
}
|
||||
|
||||
/** Renderer-supplied connection data on save; secrets here are plaintext on purpose (one-way, encrypted before persisting). */
|
||||
@@ -50,6 +52,8 @@ export interface SshConnectionInput {
|
||||
askPassphraseAtConnect: boolean
|
||||
keyPath?: string
|
||||
keepaliveIntervalSec: number
|
||||
/** highlight profile bound to this host; absent = run every rule */
|
||||
highlightProfileId?: string
|
||||
/** plain password to store; omit to keep the previously stored one */
|
||||
password?: string
|
||||
/** private key content to store inline; omit to keep the previously stored one */
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { isHighlightCategory, type HighlightRule } from './settings'
|
||||
|
||||
/**
|
||||
* Import / export of highlight rule sets — share a set with a colleague, or keep
|
||||
* a copy before experimenting.
|
||||
*
|
||||
* The envelope carries a kind + version so pasting the wrong JSON (a layout, a
|
||||
* connection list) fails loudly instead of importing nonsense. Validation here
|
||||
* stays deliberately light: it only rejects what cannot be a rule at all
|
||||
* (unparseable JSON, no `rules` array, an unusable regex). Everything else is
|
||||
* repaired by the settings store on save, which already owns that job.
|
||||
*/
|
||||
|
||||
export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules'
|
||||
export const HIGHLIGHT_FILE_VERSION = 1
|
||||
|
||||
/** Default colour for an imported rule that lost its own. */
|
||||
const FALLBACK_FG = '#3fb950'
|
||||
|
||||
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||
|
||||
export interface HighlightFile {
|
||||
kind: string
|
||||
version: number
|
||||
exportedAt: string
|
||||
rules: HighlightRule[]
|
||||
}
|
||||
|
||||
/** Reasons a paste can be refused, as codes the UI turns into a message. */
|
||||
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind'
|
||||
|
||||
export interface ParsedHighlightFile {
|
||||
rules: HighlightRule[]
|
||||
/** One line per rule that was dropped, for the "N skipped" note. */
|
||||
warnings: string[]
|
||||
error?: ImportError
|
||||
}
|
||||
|
||||
/** Serialize a rule set into the shareable envelope. */
|
||||
export function exportHighlightRules(rules: HighlightRule[]): string {
|
||||
const file: HighlightFile = {
|
||||
kind: HIGHLIGHT_FILE_KIND,
|
||||
version: HIGHLIGHT_FILE_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
rules
|
||||
}
|
||||
return JSON.stringify(file, null, 2)
|
||||
}
|
||||
|
||||
/** Id for an imported rule, so it can never collide with an existing one. */
|
||||
function freshId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `imported-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/** Accepts the exported envelope, or a bare array of rules. */
|
||||
export function parseHighlightRules(text: string): ParsedHighlightFile {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(text)
|
||||
} catch {
|
||||
return { rules: [], warnings: [], error: 'not-json' }
|
||||
}
|
||||
|
||||
let list: unknown[]
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw
|
||||
} else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) {
|
||||
const kind = (raw as { kind?: unknown }).kind
|
||||
if (typeof kind === 'string' && kind !== HIGHLIGHT_FILE_KIND) {
|
||||
return { rules: [], warnings: [], error: 'wrong-kind' }
|
||||
}
|
||||
list = (raw as { rules: unknown[] }).rules
|
||||
} else {
|
||||
return { rules: [], warnings: [], error: 'no-rules' }
|
||||
}
|
||||
|
||||
const rules: HighlightRule[] = []
|
||||
const warnings: string[] = []
|
||||
list.forEach((entry, index) => {
|
||||
const rule = coerceImported(entry, index, warnings)
|
||||
if (rule) rules.push(rule)
|
||||
})
|
||||
return { rules, warnings }
|
||||
}
|
||||
|
||||
/** Keep the fields we understand; the store repairs the rest on save. */
|
||||
function coerceImported(value: unknown, index: number, warnings: string[]): HighlightRule | null {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
warnings.push(`#${index + 1}: not an object`)
|
||||
return null
|
||||
}
|
||||
const raw = value as Record<string, unknown>
|
||||
const pattern = typeof raw.pattern === 'string' ? raw.pattern.trim() : ''
|
||||
if (pattern === '') {
|
||||
warnings.push(`#${index + 1}: no pattern`)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(pattern)
|
||||
} catch {
|
||||
warnings.push(`#${index + 1}: invalid regex`)
|
||||
return null
|
||||
}
|
||||
|
||||
const color = raw.color !== null && typeof raw.color === 'object' ? (raw.color as Record<string, unknown>) : null
|
||||
const fg = typeof color?.fg === 'string' && HEX.test(color.fg) ? color.fg : FALLBACK_FG
|
||||
const bg = typeof color?.bg === 'string' && HEX.test(color.bg) ? color.bg : undefined
|
||||
const rawPriority = typeof raw.priority === 'number' ? raw.priority : Number(raw.priority)
|
||||
|
||||
return {
|
||||
id: freshId(),
|
||||
pattern,
|
||||
enabled: raw.enabled !== false,
|
||||
priority: Number.isFinite(rawPriority) ? Math.min(100, Math.max(1, Math.round(rawPriority))) : 10,
|
||||
color: { fg, ...(bg ? { bg } : {}) },
|
||||
...(Array.isArray(raw.bands) ? { bands: raw.bands as HighlightRule['bands'] } : {}),
|
||||
...(raw.caseInsensitive === true ? { caseInsensitive: true } : {}),
|
||||
...(raw.basic === true ? { basic: true } : {}),
|
||||
...(isHighlightCategory(raw.category) ? { category: raw.category } : {}),
|
||||
...(typeof raw.note === 'string' && raw.note.trim() !== '' ? { note: raw.note.trim() } : {}),
|
||||
builtin: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge imported rules into the current set: replace everything, or append. */
|
||||
export function mergeRules(
|
||||
existing: HighlightRule[],
|
||||
incoming: HighlightRule[],
|
||||
mode: 'replace' | 'append'
|
||||
): HighlightRule[] {
|
||||
if (mode === 'replace') return incoming
|
||||
const used = new Set(existing.map((rule) => rule.id))
|
||||
const appended = incoming.map((rule) => {
|
||||
if (!used.has(rule.id)) {
|
||||
used.add(rule.id)
|
||||
return rule
|
||||
}
|
||||
const id = freshId()
|
||||
used.add(id)
|
||||
return { ...rule, id }
|
||||
})
|
||||
return [...existing, ...appended]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { HighlightProfile, HighlightRule } from './settings'
|
||||
|
||||
/**
|
||||
* Per-host highlight profiles.
|
||||
*
|
||||
* A profile is a named subset of the rule list, and an SSH connection can be
|
||||
* bound to one so a production host runs a quieter set than a local shell. The
|
||||
* master switch (`terminal.highlightPerHost`) decides whether any of this is
|
||||
* consulted: with it off, `rulesForProfile` is never called and every session
|
||||
* runs the full set exactly as before.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate a profile list coming from disk. Ids and names are required, rule
|
||||
* references that no longer exist are dropped (deleting a rule must not leave a
|
||||
* profile pointing at nothing), and duplicate ids are refused because the UI
|
||||
* keys rows by them.
|
||||
*/
|
||||
export function sanitizeProfiles(
|
||||
value: unknown,
|
||||
knownRuleIds: Set<string>,
|
||||
warn: (message: string) => void
|
||||
): HighlightProfile[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const profiles: HighlightProfile[] = []
|
||||
const seen = new Set<string>()
|
||||
value.forEach((entry, index) => {
|
||||
if (entry === null || typeof entry !== 'object') {
|
||||
warn(`highlightProfiles[${index}]: not an object — skipped`)
|
||||
return
|
||||
}
|
||||
const raw = entry as Record<string, unknown>
|
||||
if (typeof raw.id !== 'string' || raw.id === '' || typeof raw.name !== 'string') {
|
||||
warn(`highlightProfiles[${index}]: missing id/name — skipped`)
|
||||
return
|
||||
}
|
||||
if (seen.has(raw.id)) {
|
||||
warn(`highlightProfiles[${index}]: duplicate id — skipped`)
|
||||
return
|
||||
}
|
||||
seen.add(raw.id)
|
||||
|
||||
const ruleIds: string[] = []
|
||||
if (Array.isArray(raw.ruleIds)) {
|
||||
for (const id of raw.ruleIds) {
|
||||
if (typeof id !== 'string') continue
|
||||
if (!knownRuleIds.has(id)) {
|
||||
warn(`highlightProfiles[${index}]: unknown rule "${id}" dropped`)
|
||||
continue
|
||||
}
|
||||
if (!ruleIds.includes(id)) ruleIds.push(id)
|
||||
}
|
||||
}
|
||||
profiles.push({ id: raw.id, name: raw.name, ruleIds })
|
||||
})
|
||||
return profiles
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules a session runs: the bound profile's subset, or everything when no
|
||||
* profile is bound, the profile is gone, or it selects nothing.
|
||||
*/
|
||||
export function rulesForProfile(
|
||||
rules: HighlightRule[],
|
||||
profileId: string | undefined,
|
||||
profiles: HighlightProfile[]
|
||||
): HighlightRule[] {
|
||||
if (profileId === undefined) return rules
|
||||
const profile = profiles.find((candidate) => candidate.id === profileId)
|
||||
if (profile === undefined || profile.ruleIds.length === 0) return rules
|
||||
const wanted = new Set(profile.ruleIds)
|
||||
return rules.filter((rule) => wanted.has(rule.id))
|
||||
}
|
||||
|
||||
/** Rules a profile leaves out — shown as a hint in the editor. */
|
||||
export function excludedByProfile(
|
||||
rules: HighlightRule[],
|
||||
profile: HighlightProfile | undefined
|
||||
): HighlightRule[] {
|
||||
if (profile === undefined) return []
|
||||
const wanted = new Set(profile.ruleIds)
|
||||
return rules.filter((rule) => !wanted.has(rule.id))
|
||||
}
|
||||
@@ -117,6 +117,72 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.palette': 'Palette',
|
||||
'settings.highlight.note': 'Note',
|
||||
'settings.highlight.notePlaceholder': 'Rule description (optional)',
|
||||
'settings.highlight.caseInsensitive': 'Ignore case',
|
||||
'settings.highlight.caseInsensitiveHint': 'letters match in any case (OK also matches ok)',
|
||||
'settings.highlight.bands': 'Value bands',
|
||||
'settings.highlight.bandsHint':
|
||||
'Colour by the first number in the match; no number (or below the lowest band) keeps the foreground colour',
|
||||
'settings.highlight.bandMin': 'Min',
|
||||
'settings.highlight.addBand': '+ Add band',
|
||||
'settings.highlight.removeBand': 'Remove band',
|
||||
'settings.highlight.importExport': 'Import / export',
|
||||
'settings.highlight.importExportTitle': 'Import / export highlight rules',
|
||||
'settings.highlight.exportSection': 'Export — copy or save this JSON to share the set',
|
||||
'settings.highlight.copy': 'Copy',
|
||||
'settings.highlight.copied': 'Copied',
|
||||
'settings.highlight.download': 'Save as file',
|
||||
'settings.highlight.importSection': 'Import — paste JSON, or pick a .json file',
|
||||
'settings.highlight.chooseFile': 'Choose file',
|
||||
'settings.highlight.importMode': 'Mode',
|
||||
'settings.highlight.importReplace': 'Replace current rules',
|
||||
'settings.highlight.importAppend': 'Append to current rules',
|
||||
'settings.highlight.importOk': 'Import',
|
||||
'settings.highlight.importEmpty': 'No usable rules found',
|
||||
'settings.highlight.importBadJson': 'Not valid JSON',
|
||||
'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file',
|
||||
'settings.highlight.importSkipped': 'Skipped {n}: {list}',
|
||||
'settings.highlight.imported': 'Imported {n} rule(s)',
|
||||
'settings.highlight.testText': 'Test text',
|
||||
'settings.highlight.testHint': 'see the effect as you edit the pattern (first 2000 characters only)',
|
||||
'settings.highlight.mode': 'Highlighting',
|
||||
'settings.highlight.modeAll': 'All',
|
||||
'settings.highlight.modeBasic': 'Basic only',
|
||||
'settings.highlight.modeOff': 'Off',
|
||||
'settings.highlight.modeDesc':
|
||||
'Basic only: keeps just the key rules (errors, success, warnings, dangerous commands, secrets) — cheaper and quieter. Off: no colour injection at all (rules and colours are kept).',
|
||||
'settings.highlight.category': 'Category',
|
||||
'settings.highlight.groupByCategory': 'Group by category',
|
||||
'settings.highlight.category.safety': 'Safety',
|
||||
'settings.highlight.category.status': 'Status',
|
||||
'settings.highlight.category.file': 'Files',
|
||||
'settings.highlight.category.net': 'Network',
|
||||
'settings.highlight.category.text': 'Text',
|
||||
'settings.highlight.category.metric': 'Metrics',
|
||||
'settings.highlight.category.none': 'Uncategorised',
|
||||
'settings.highlight.stats': 'Count hits and time',
|
||||
'settings.highlight.statsHint':
|
||||
'Collect per-rule hit counts and durations for this page (zero cost while off)',
|
||||
'settings.highlight.statsHits': 'Hits',
|
||||
'settings.highlight.statsMs': 'Time',
|
||||
'settings.highlight.statsReset': 'Reset stats',
|
||||
'settings.highlight.statsEmpty': 'No stats yet — run some output in a terminal first',
|
||||
'settings.highlight.themeColors': 'Follow theme colours',
|
||||
'settings.highlight.themeColorsHint':
|
||||
'Map highlight colours onto the active terminal palette by hue (neutral greys are kept as-is)',
|
||||
'settings.highlight.perHost': 'Per-host rule sets',
|
||||
'settings.highlight.perHostHint':
|
||||
'When on, an SSH connection bound to a rule set runs only that subset; when off every session uses all rules',
|
||||
'settings.highlight.profiles': 'Host rule sets',
|
||||
'settings.highlight.profilesHint':
|
||||
'Save a subset of the rules under a name, then bind it to a host in the connection editor',
|
||||
'settings.highlight.profileNew': '+ New rule set',
|
||||
'settings.highlight.profileEdit': 'Edit rule set',
|
||||
'settings.highlight.profileName': 'Name',
|
||||
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
|
||||
'settings.highlight.profileRules': 'Included rules',
|
||||
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
|
||||
'settings.highlight.profileEmpty': 'No rule sets yet',
|
||||
'settings.highlight.profileDeleteTitle': 'Delete rule set',
|
||||
'settings.highlight.actions': 'Actions',
|
||||
'settings.highlight.addRule': '+ Add rule',
|
||||
'settings.highlight.empty': 'No highlight rules yet — click "+ Add rule" to create one',
|
||||
@@ -129,8 +195,18 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.perm': 'Linux permissions and user/group',
|
||||
'settings.highlight.builtin.path': 'Linux file paths',
|
||||
'settings.highlight.builtin.shellkw': 'Shell keywords and control flow',
|
||||
'settings.highlight.builtin.danger': 'Dangerous / destructive commands',
|
||||
'settings.highlight.builtin.secret': 'Possible secret / token',
|
||||
'settings.highlight.builtin.loglevel': 'Log levels',
|
||||
'settings.highlight.builtin.rootat': 'Root prompt (root@)',
|
||||
'settings.highlight.builtin.exitcode': 'Exit codes',
|
||||
'settings.highlight.builtin.http': 'HTTP status codes',
|
||||
'settings.highlight.builtin.latency': 'Durations (milliseconds)',
|
||||
'settings.highlight.builtin.delop': 'Delete / move / overwrite operations',
|
||||
'settings.highlight.builtin.createop': 'Create / add operations',
|
||||
'settings.highlight.builtin.okstate': 'Success states',
|
||||
'settings.highlight.builtin.badstate': 'Error/warning states',
|
||||
'settings.highlight.builtin.warnstate': 'Warnings and TODO markers',
|
||||
'settings.highlight.builtin.badstate': 'Error / failure states',
|
||||
'settings.highlight.builtin.quoted': 'Strings and quotes',
|
||||
'settings.highlight.builtin.envvar': 'Environment variables and arguments',
|
||||
'settings.highlight.builtin.ipv4': 'Network and IP addresses',
|
||||
|
||||
@@ -43,6 +43,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.dialog.keepalive': 'Keepalive interval (seconds)',
|
||||
'ssh.dialog.keepaliveTooltip': '0 disables keepalive',
|
||||
'ssh.dialog.keepaliveMin': 'Cannot be negative',
|
||||
'ssh.dialog.highlightProfile': 'Highlight rule set',
|
||||
'ssh.dialog.highlightProfileDefault': 'Default (all rules)',
|
||||
|
||||
// Connection sidebar
|
||||
'ssh.sidebar.defaultGroup': 'Default',
|
||||
|
||||
@@ -114,6 +114,70 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.palette': 'パレット',
|
||||
'settings.highlight.note': 'メモ',
|
||||
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
|
||||
'settings.highlight.caseInsensitive': '大文字小文字を無視',
|
||||
'settings.highlight.caseInsensitiveHint': '大文字小文字を区別しない(OK は ok にも一致)',
|
||||
'settings.highlight.bands': '数値の段階',
|
||||
'settings.highlight.bandsHint': 'マッチ内の最初の数値で色を決めます(数値なし・最小段階未満は前景色)',
|
||||
'settings.highlight.bandMin': '下限',
|
||||
'settings.highlight.addBand': '+ 段階を追加',
|
||||
'settings.highlight.removeBand': '段階を削除',
|
||||
'settings.highlight.importExport': 'インポート / エクスポート',
|
||||
'settings.highlight.importExportTitle': 'ハイライトルールのインポート / エクスポート',
|
||||
'settings.highlight.exportSection': 'エクスポート(下の JSON をコピー/保存して共有)',
|
||||
'settings.highlight.copy': 'コピー',
|
||||
'settings.highlight.copied': 'コピーしました',
|
||||
'settings.highlight.download': 'ファイルとして保存',
|
||||
'settings.highlight.importSection': 'インポート(JSON を貼り付け、または .json を選択)',
|
||||
'settings.highlight.chooseFile': 'ファイルを選択',
|
||||
'settings.highlight.importMode': '取り込み方法',
|
||||
'settings.highlight.importReplace': '現在のルールを置き換える',
|
||||
'settings.highlight.importAppend': '現在のルールに追加する',
|
||||
'settings.highlight.importOk': 'インポート',
|
||||
'settings.highlight.importEmpty': '有効なルールが見つかりません',
|
||||
'settings.highlight.importBadJson': '有効な JSON ではありません',
|
||||
'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません',
|
||||
'settings.highlight.importSkipped': '{n} 件をスキップ:{list}',
|
||||
'settings.highlight.imported': '{n} 件のルールを取り込みました',
|
||||
'settings.highlight.testText': 'テスト文字列',
|
||||
'settings.highlight.testHint': '正規表現を編集すると即座に反映されます(先頭 2000 文字のみ)',
|
||||
'settings.highlight.mode': 'ハイライト',
|
||||
'settings.highlight.modeAll': 'すべて',
|
||||
'settings.highlight.modeBasic': '基本のみ',
|
||||
'settings.highlight.modeOff': 'オフ',
|
||||
'settings.highlight.modeDesc':
|
||||
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
|
||||
'settings.highlight.category': '分類',
|
||||
'settings.highlight.groupByCategory': '分類でグループ化',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
'settings.highlight.category.status': 'ステータス',
|
||||
'settings.highlight.category.file': 'ファイル',
|
||||
'settings.highlight.category.net': 'ネットワーク',
|
||||
'settings.highlight.category.text': 'テキスト',
|
||||
'settings.highlight.category.metric': '数値',
|
||||
'settings.highlight.category.none': '未分類',
|
||||
'settings.highlight.stats': 'ヒット数と所要時間を計測',
|
||||
'settings.highlight.statsHint': 'ルールごとのヒット数と所要時間を計測します(オフのときはコストゼロ)',
|
||||
'settings.highlight.statsHits': 'ヒット',
|
||||
'settings.highlight.statsMs': '所要時間',
|
||||
'settings.highlight.statsReset': '統計をリセット',
|
||||
'settings.highlight.statsEmpty': 'まだ統計がありません。端末で出力を流してください',
|
||||
'settings.highlight.themeColors': 'テーマの配色に合わせる',
|
||||
'settings.highlight.themeColorsHint':
|
||||
'色相でハイライト色を現在の端末パレットに割り当てます(無彩色はそのまま)',
|
||||
'settings.highlight.perHost': 'ホスト別ルールセット',
|
||||
'settings.highlight.perHostHint':
|
||||
'オンにすると、ルールセットを紐付けた SSH 接続はそのサブセットのみ適用されます。オフでは全セッションが全ルールを使います',
|
||||
'settings.highlight.profiles': 'ホスト用ルールセット',
|
||||
'settings.highlight.profilesHint':
|
||||
'ルールのサブセットに名前を付けて保存し、接続の編集でホストに紐付けます',
|
||||
'settings.highlight.profileNew': '+ ルールセットを作成',
|
||||
'settings.highlight.profileEdit': 'ルールセットを編集',
|
||||
'settings.highlight.profileName': '名前',
|
||||
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
|
||||
'settings.highlight.profileRules': '含めるルール',
|
||||
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
|
||||
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
|
||||
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
|
||||
'settings.highlight.actions': '操作',
|
||||
'settings.highlight.addRule': '+ ルールを追加',
|
||||
'settings.highlight.empty': 'ハイライトルールがありません。「+ ルールを追加」から作成できます',
|
||||
@@ -126,8 +190,18 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.perm': 'Linux のパーミッションとユーザー',
|
||||
'settings.highlight.builtin.path': 'Linux のファイルパス',
|
||||
'settings.highlight.builtin.shellkw': 'Shell のキーワードと制御構文',
|
||||
'settings.highlight.builtin.danger': '危険・破壊的なコマンド',
|
||||
'settings.highlight.builtin.secret': 'シークレット・トークンの疑い',
|
||||
'settings.highlight.builtin.loglevel': 'ログレベル',
|
||||
'settings.highlight.builtin.rootat': 'root プロンプト(root@)',
|
||||
'settings.highlight.builtin.exitcode': '終了コード',
|
||||
'settings.highlight.builtin.http': 'HTTP ステータスコード',
|
||||
'settings.highlight.builtin.latency': '所要時間(ミリ秒)',
|
||||
'settings.highlight.builtin.delop': '削除・移動・上書きの操作',
|
||||
'settings.highlight.builtin.createop': '作成・追加の操作',
|
||||
'settings.highlight.builtin.okstate': '成功ステータス',
|
||||
'settings.highlight.builtin.badstate': 'エラー・警告ステータス',
|
||||
'settings.highlight.builtin.warnstate': '警告と TODO マーカー',
|
||||
'settings.highlight.builtin.badstate': 'エラー・失敗ステータス',
|
||||
'settings.highlight.builtin.quoted': '文字列と引用符',
|
||||
'settings.highlight.builtin.envvar': '環境変数と引数',
|
||||
'settings.highlight.builtin.ipv4': 'ネットワークと IP アドレス',
|
||||
|
||||
@@ -45,6 +45,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.dialog.keepalive': 'Keepalive 間隔(秒)',
|
||||
'ssh.dialog.keepaliveTooltip': '0 で keepalive を無効にします',
|
||||
'ssh.dialog.keepaliveMin': '負の値は指定できません',
|
||||
'ssh.dialog.highlightProfile': 'ハイライトルールセット',
|
||||
'ssh.dialog.highlightProfileDefault': '既定(すべてのルール)',
|
||||
|
||||
// 接続サイドバー
|
||||
'ssh.sidebar.defaultGroup': 'デフォルト',
|
||||
|
||||
@@ -111,6 +111,66 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.palette': '色板',
|
||||
'settings.highlight.note': '备注',
|
||||
'settings.highlight.notePlaceholder': '规则说明(可选)',
|
||||
'settings.highlight.caseInsensitive': '忽略大小写',
|
||||
'settings.highlight.caseInsensitiveHint': '字母不区分大小写(OK 也能匹配 ok)',
|
||||
'settings.highlight.bands': '数值分级',
|
||||
'settings.highlight.bandsHint': '按匹配到的第一个数字取色;没有数字或低于最小分级时用前景色',
|
||||
'settings.highlight.bandMin': '下限',
|
||||
'settings.highlight.addBand': '+ 添加分级',
|
||||
'settings.highlight.removeBand': '删除分级',
|
||||
'settings.highlight.importExport': '导入 / 导出',
|
||||
'settings.highlight.importExportTitle': '导入 / 导出高亮规则',
|
||||
'settings.highlight.exportSection': '导出(复制或保存下面的 JSON 分享给别人)',
|
||||
'settings.highlight.copy': '复制',
|
||||
'settings.highlight.copied': '已复制',
|
||||
'settings.highlight.download': '保存为文件',
|
||||
'settings.highlight.importSection': '导入(粘贴 JSON,或选择 .json 文件)',
|
||||
'settings.highlight.chooseFile': '选择文件',
|
||||
'settings.highlight.importMode': '导入方式',
|
||||
'settings.highlight.importReplace': '替换现有规则',
|
||||
'settings.highlight.importAppend': '追加到现有规则',
|
||||
'settings.highlight.importOk': '导入',
|
||||
'settings.highlight.importEmpty': '没有解析到可用规则',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件',
|
||||
'settings.highlight.importSkipped': '已跳过 {n} 条:{list}',
|
||||
'settings.highlight.imported': '已导入 {n} 条规则',
|
||||
'settings.highlight.testText': '测试文本',
|
||||
'settings.highlight.testHint': '改完正则立刻看到效果(仅预览前 2000 字符)',
|
||||
'settings.highlight.mode': '高亮模式',
|
||||
'settings.highlight.modeAll': '全部',
|
||||
'settings.highlight.modeBasic': '仅基础',
|
||||
'settings.highlight.modeOff': '关闭',
|
||||
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
|
||||
'settings.highlight.category': '分类',
|
||||
'settings.highlight.groupByCategory': '按分类分组',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
'settings.highlight.category.status': '状态',
|
||||
'settings.highlight.category.file': '文件',
|
||||
'settings.highlight.category.net': '网络',
|
||||
'settings.highlight.category.text': '文本',
|
||||
'settings.highlight.category.metric': '数值',
|
||||
'settings.highlight.category.none': '未分类',
|
||||
'settings.highlight.stats': '统计命中与耗时',
|
||||
'settings.highlight.statsHint': '开启后统计每条规则的命中次数与耗时(设置页显示,关闭时零开销)',
|
||||
'settings.highlight.statsHits': '命中',
|
||||
'settings.highlight.statsMs': '耗时',
|
||||
'settings.highlight.statsReset': '重置统计',
|
||||
'settings.highlight.statsEmpty': '还没有统计数据,先去终端里跑点输出',
|
||||
'settings.highlight.themeColors': '跟随主题配色',
|
||||
'settings.highlight.themeColorsHint': '按色相把高亮颜色映射到当前终端主题的调色板(灰阶色保持原样)',
|
||||
'settings.highlight.perHost': '按主机规则集',
|
||||
'settings.highlight.perHostHint': '开启后,绑定了规则集的 SSH 连接只跑该子集;关闭时所有会话都用全部规则',
|
||||
'settings.highlight.profiles': '主机规则集',
|
||||
'settings.highlight.profilesHint': '把规则的子集存成命名规则集,在「连接编辑」里绑定给某台主机',
|
||||
'settings.highlight.profileNew': '+ 新建规则集',
|
||||
'settings.highlight.profileEdit': '编辑规则集',
|
||||
'settings.highlight.profileName': '名称',
|
||||
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
|
||||
'settings.highlight.profileRules': '包含的规则',
|
||||
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
|
||||
'settings.highlight.profileEmpty': '尚未创建规则集',
|
||||
'settings.highlight.profileDeleteTitle': '删除规则集',
|
||||
'settings.highlight.actions': '操作',
|
||||
'settings.highlight.addRule': '+ 新增规则',
|
||||
'settings.highlight.empty': '暂无高亮规则,点击「+ 新增规则」创建',
|
||||
@@ -123,8 +183,18 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.perm': 'Linux 权限与用户',
|
||||
'settings.highlight.builtin.path': 'Linux 文件路径',
|
||||
'settings.highlight.builtin.shellkw': 'Shell 关键字与流程',
|
||||
'settings.highlight.builtin.danger': '危险/破坏性命令',
|
||||
'settings.highlight.builtin.secret': '疑似密钥/令牌',
|
||||
'settings.highlight.builtin.loglevel': '日志级别',
|
||||
'settings.highlight.builtin.rootat': '管理员提示符 root@',
|
||||
'settings.highlight.builtin.exitcode': '退出码',
|
||||
'settings.highlight.builtin.http': 'HTTP 状态码',
|
||||
'settings.highlight.builtin.latency': '耗时(毫秒)',
|
||||
'settings.highlight.builtin.delop': '删除/移动/覆盖操作',
|
||||
'settings.highlight.builtin.createop': '新建/创建操作',
|
||||
'settings.highlight.builtin.okstate': '成功状态',
|
||||
'settings.highlight.builtin.badstate': '错误/警告状态',
|
||||
'settings.highlight.builtin.warnstate': '警告与待办标记',
|
||||
'settings.highlight.builtin.badstate': '错误/失败状态',
|
||||
'settings.highlight.builtin.quoted': '字符串与引号',
|
||||
'settings.highlight.builtin.envvar': '环境变量与参数',
|
||||
'settings.highlight.builtin.ipv4': '网络与 IP 地址',
|
||||
|
||||
@@ -41,6 +41,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.dialog.keepalive': 'Keepalive 间隔(秒)',
|
||||
'ssh.dialog.keepaliveTooltip': '0 表示关闭 keepalive',
|
||||
'ssh.dialog.keepaliveMin': '不能为负数',
|
||||
'ssh.dialog.highlightProfile': '高亮规则集',
|
||||
'ssh.dialog.highlightProfileDefault': '默认(全部规则)',
|
||||
|
||||
// 连接侧栏
|
||||
'ssh.sidebar.defaultGroup': '默认',
|
||||
|
||||
@@ -111,6 +111,66 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.palette': '色盤',
|
||||
'settings.highlight.note': '備註',
|
||||
'settings.highlight.notePlaceholder': '規則說明(選填)',
|
||||
'settings.highlight.caseInsensitive': '忽略大小寫',
|
||||
'settings.highlight.caseInsensitiveHint': '字母不區分大小寫(OK 也能匹配 ok)',
|
||||
'settings.highlight.bands': '數值分級',
|
||||
'settings.highlight.bandsHint': '依匹配到的第一個數字取色;沒有數字或低於最小分級時用前景色',
|
||||
'settings.highlight.bandMin': '下限',
|
||||
'settings.highlight.addBand': '+ 新增分級',
|
||||
'settings.highlight.removeBand': '刪除分級',
|
||||
'settings.highlight.importExport': '匯入 / 匯出',
|
||||
'settings.highlight.importExportTitle': '匯入 / 匯出高亮規則',
|
||||
'settings.highlight.exportSection': '匯出(複製或儲存下面的 JSON 分享給別人)',
|
||||
'settings.highlight.copy': '複製',
|
||||
'settings.highlight.copied': '已複製',
|
||||
'settings.highlight.download': '儲存為檔案',
|
||||
'settings.highlight.importSection': '匯入(貼上 JSON,或選擇 .json 檔案)',
|
||||
'settings.highlight.chooseFile': '選擇檔案',
|
||||
'settings.highlight.importMode': '匯入方式',
|
||||
'settings.highlight.importReplace': '取代現有規則',
|
||||
'settings.highlight.importAppend': '附加到現有規則',
|
||||
'settings.highlight.importOk': '匯入',
|
||||
'settings.highlight.importEmpty': '沒有解析到可用規則',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案',
|
||||
'settings.highlight.importSkipped': '已略過 {n} 條:{list}',
|
||||
'settings.highlight.imported': '已匯入 {n} 條規則',
|
||||
'settings.highlight.testText': '測試文字',
|
||||
'settings.highlight.testHint': '改完正規表示式立刻看到效果(僅預覽前 2000 字元)',
|
||||
'settings.highlight.mode': '高亮模式',
|
||||
'settings.highlight.modeAll': '全部',
|
||||
'settings.highlight.modeBasic': '僅基礎',
|
||||
'settings.highlight.modeOff': '關閉',
|
||||
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
|
||||
'settings.highlight.category': '分類',
|
||||
'settings.highlight.groupByCategory': '依分類分組',
|
||||
'settings.highlight.category.safety': '安全',
|
||||
'settings.highlight.category.status': '狀態',
|
||||
'settings.highlight.category.file': '檔案',
|
||||
'settings.highlight.category.net': '網路',
|
||||
'settings.highlight.category.text': '文字',
|
||||
'settings.highlight.category.metric': '數值',
|
||||
'settings.highlight.category.none': '未分類',
|
||||
'settings.highlight.stats': '統計命中與耗時',
|
||||
'settings.highlight.statsHint': '開啟後統計每條規則的命中次數與耗時(設定頁顯示,關閉時零開銷)',
|
||||
'settings.highlight.statsHits': '命中',
|
||||
'settings.highlight.statsMs': '耗時',
|
||||
'settings.highlight.statsReset': '重設統計',
|
||||
'settings.highlight.statsEmpty': '還沒有統計資料,先去終端機跑點輸出',
|
||||
'settings.highlight.themeColors': '跟隨主題配色',
|
||||
'settings.highlight.themeColorsHint': '依色相把高亮顏色對應到目前終端機主題的調色盤(灰階色保持原樣)',
|
||||
'settings.highlight.perHost': '依主機規則集',
|
||||
'settings.highlight.perHostHint': '開啟後,綁定了規則集的 SSH 連線只跑該子集;關閉時所有工作階段都用全部規則',
|
||||
'settings.highlight.profiles': '主機規則集',
|
||||
'settings.highlight.profilesHint': '把規則的子集存成命名規則集,在「編輯連線」裡綁定給某台主機',
|
||||
'settings.highlight.profileNew': '+ 新增規則集',
|
||||
'settings.highlight.profileEdit': '編輯規則集',
|
||||
'settings.highlight.profileName': '名稱',
|
||||
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
|
||||
'settings.highlight.profileRules': '包含的規則',
|
||||
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
|
||||
'settings.highlight.profileEmpty': '尚未建立規則集',
|
||||
'settings.highlight.profileDeleteTitle': '刪除規則集',
|
||||
'settings.highlight.actions': '操作',
|
||||
'settings.highlight.addRule': '+ 新增規則',
|
||||
'settings.highlight.empty': '尚無高亮規則,點擊「+ 新增規則」建立',
|
||||
@@ -123,8 +183,18 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.perm': 'Linux 權限與使用者',
|
||||
'settings.highlight.builtin.path': 'Linux 檔案路徑',
|
||||
'settings.highlight.builtin.shellkw': 'Shell 關鍵字與流程',
|
||||
'settings.highlight.builtin.danger': '危險/破壞性命令',
|
||||
'settings.highlight.builtin.secret': '疑似密鑰/權杖',
|
||||
'settings.highlight.builtin.loglevel': '日誌等級',
|
||||
'settings.highlight.builtin.rootat': '管理員提示字元 root@',
|
||||
'settings.highlight.builtin.exitcode': '結束碼',
|
||||
'settings.highlight.builtin.http': 'HTTP 狀態碼',
|
||||
'settings.highlight.builtin.latency': '耗時(毫秒)',
|
||||
'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作',
|
||||
'settings.highlight.builtin.createop': '新建/建立操作',
|
||||
'settings.highlight.builtin.okstate': '成功狀態',
|
||||
'settings.highlight.builtin.badstate': '錯誤/警告狀態',
|
||||
'settings.highlight.builtin.warnstate': '警告與待辦標記',
|
||||
'settings.highlight.builtin.badstate': '錯誤/失敗狀態',
|
||||
'settings.highlight.builtin.quoted': '字串與引號',
|
||||
'settings.highlight.builtin.envvar': '環境變數與參數',
|
||||
'settings.highlight.builtin.ipv4': '網路與 IP 位址',
|
||||
|
||||
@@ -41,6 +41,8 @@ const ssh: Record<string, string> = {
|
||||
'ssh.dialog.keepalive': 'Keepalive 間隔(秒)',
|
||||
'ssh.dialog.keepaliveTooltip': '0 表示關閉 keepalive',
|
||||
'ssh.dialog.keepaliveMin': '不能為負數',
|
||||
'ssh.dialog.highlightProfile': '高亮規則集',
|
||||
'ssh.dialog.highlightProfileDefault': '預設(全部規則)',
|
||||
|
||||
// 連線側欄
|
||||
'ssh.sidebar.defaultGroup': '預設',
|
||||
|
||||
+295
-8
@@ -1,6 +1,23 @@
|
||||
import type { TerminalTheme } from './theme'
|
||||
import type { Language } from './i18n'
|
||||
|
||||
/** Coarse grouping for the settings table's grouped view. */
|
||||
export type HighlightCategory = 'safety' | 'status' | 'file' | 'net' | 'text' | 'metric'
|
||||
|
||||
/** Stable order for the category column, the filter list and the editor select. */
|
||||
export const HIGHLIGHT_CATEGORIES: HighlightCategory[] = [
|
||||
'safety',
|
||||
'status',
|
||||
'file',
|
||||
'net',
|
||||
'text',
|
||||
'metric'
|
||||
]
|
||||
|
||||
export function isHighlightCategory(value: unknown): value is HighlightCategory {
|
||||
return typeof value === 'string' && (HIGHLIGHT_CATEGORIES as string[]).includes(value)
|
||||
}
|
||||
|
||||
/** One terminal keyword-highlight rule (regex → ANSI color injection). */
|
||||
export interface HighlightRule {
|
||||
id: string
|
||||
@@ -11,6 +28,21 @@ export interface HighlightRule {
|
||||
priority: number
|
||||
/** truecolor hex (#rrggbb); fg required, bg optional */
|
||||
color: { fg: string; bg?: string }
|
||||
/**
|
||||
* Value bands: the first number found in the match picks the colour, lowest
|
||||
* `min` first. `fg` above stays the fallback for matches without a number or
|
||||
* below the smallest `min`.
|
||||
*/
|
||||
bands?: { min: number; fg: string }[]
|
||||
/** match without regard to letter case (compiled with the 'i' flag) */
|
||||
caseInsensitive?: boolean
|
||||
/**
|
||||
* Part of the "basic" set: still applied when the highlight mode is
|
||||
* `basic` (the cheap, low-noise subset — status, danger, secrets).
|
||||
*/
|
||||
basic?: boolean
|
||||
/** Grouping shown by the settings table's category column / grouped view. */
|
||||
category?: HighlightCategory
|
||||
note?: string
|
||||
/** part of the factory preset set (restored by 恢复默认) */
|
||||
builtin?: boolean
|
||||
@@ -49,12 +81,50 @@ export interface TerminalSettings {
|
||||
showOpenCwdButton: boolean
|
||||
/** accent color for the active tab outline, rail indicator and SSH badges */
|
||||
tabAccentColor: string
|
||||
/**
|
||||
* How much keyword highlighting runs: `all` uses every enabled rule, `basic`
|
||||
* only the rules flagged `basic` (status, danger, secrets), `off` injects no
|
||||
* colour at all. The rule set itself is never touched by this.
|
||||
*/
|
||||
highlightMode: HighlightMode
|
||||
/**
|
||||
* Settings-table view: cluster rules by category (adds a category column and
|
||||
* sorts by it) instead of listing them in plain priority order.
|
||||
*/
|
||||
highlightGroupByCategory: boolean
|
||||
/**
|
||||
* Collect per-rule hit counts and timings for the settings page. Off by
|
||||
* default: the engine skips all counting unless a sink is attached.
|
||||
*/
|
||||
highlightStats: boolean
|
||||
/**
|
||||
* Draw highlight colours from the active terminal palette (by hue) instead of
|
||||
* the rule's own hex. Off by default: the shipped colours already read on
|
||||
* every theme, this is for users who want palette-consistent highlighting.
|
||||
*/
|
||||
highlightThemeColors: boolean
|
||||
/**
|
||||
* Consult per-host highlight profiles: an SSH connection bound to a profile
|
||||
* runs only that profile's rules. Off by default — with it off every session
|
||||
* runs the full rule set, exactly as before.
|
||||
*/
|
||||
highlightPerHost: boolean
|
||||
/** absolute path to an image rendered behind the terminal; '' = none */
|
||||
backgroundImage: string
|
||||
/** background image layer opacity, 10..100 (%) */
|
||||
backgroundImageOpacity: number
|
||||
}
|
||||
|
||||
export type HighlightMode = 'all' | 'basic' | 'off'
|
||||
|
||||
/**
|
||||
* Read a stored mode, tolerating anything a hand-edited settings.json holds: an
|
||||
* unknown value behaves like `all`, the default before modes existed.
|
||||
*/
|
||||
export function highlightModeOf(value: unknown): HighlightMode {
|
||||
return value === 'basic' || value === 'off' ? value : 'all'
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
/** register the app to launch at OS login */
|
||||
launchAtLogin: boolean
|
||||
@@ -81,12 +151,25 @@ export interface SystemSettings {
|
||||
language?: Language
|
||||
}
|
||||
|
||||
/**
|
||||
* A named subset of the highlight rules, bound to an SSH connection so a
|
||||
* production host can run a quieter set than a local shell. An empty `ruleIds`
|
||||
* means "everything", the same as not binding a profile at all.
|
||||
*/
|
||||
export interface HighlightProfile {
|
||||
id: string
|
||||
name: string
|
||||
ruleIds: string[]
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
terminal: TerminalSettings
|
||||
/** user-created themes; builtin themes are immutable and live in shared/theme.ts */
|
||||
customThemes: TerminalTheme[]
|
||||
/** terminal keyword highlight rules, applied at write time in priority order */
|
||||
highlightRules: HighlightRule[]
|
||||
/** named rule subsets for per-host highlighting (see terminal.highlightPerHost) */
|
||||
highlightProfiles: HighlightProfile[]
|
||||
system: SystemSettings
|
||||
}
|
||||
|
||||
@@ -96,23 +179,221 @@ const RULE = (
|
||||
priority: number,
|
||||
fg: string,
|
||||
note: string,
|
||||
bg?: string
|
||||
): HighlightRule => ({ id, pattern, enabled: true, priority, color: { fg, bg }, note, builtin: true })
|
||||
opts?: {
|
||||
bg?: string
|
||||
caseInsensitive?: boolean
|
||||
bands?: HighlightRule['bands']
|
||||
basic?: boolean
|
||||
}
|
||||
): HighlightRule => ({
|
||||
id,
|
||||
pattern,
|
||||
enabled: true,
|
||||
priority,
|
||||
color: { fg, bg: opts?.bg },
|
||||
note,
|
||||
// omitted (not `false`) unless set, so a preset round trip stays compact
|
||||
...(opts?.caseInsensitive ? { caseInsensitive: true } : {}),
|
||||
...(opts?.bands ? { bands: opts.bands } : {}),
|
||||
...(opts?.basic ? { basic: true } : {}),
|
||||
builtin: true
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory preset rules.
|
||||
*
|
||||
* Status words are matched case-insensitively (`\bOK\b` should fire on `ok` as
|
||||
* well as `OK`), but always behind word boundaries: `invalid` must not light up
|
||||
* `valid`, `disabled` must not light up `enabled`. The `okstate` pattern carries
|
||||
* a `not ` lookbehind so the `ok` in `not ok` (TAP, mocha, pytest summaries) is
|
||||
* left for `badstate` to claim instead of turning it green.
|
||||
*
|
||||
* Priorities put the destructive-command and file-operation rules above the
|
||||
* status words, and the status words above `shellkw`: `done` is a shell keyword
|
||||
* and a success word, `if` sits inside `dd if=`, and `drop table` belongs to
|
||||
* `danger` rather than the plain `drop` of `delop`. Whichever rule runs first
|
||||
* paints the span and the other one is skipped, so that order is the whole
|
||||
* contract between them.
|
||||
*
|
||||
* Keyword stems carry their own endings rather than a generic suffix group:
|
||||
* `DELET(?:E|ED|ES|ING|ION)` instead of `DELETE(?:D|S|ING)?`, which silently
|
||||
* misses `deleting`. Stems that are not words on their own (MOV, SAV, CLON,
|
||||
* PURG, ERAS, WIP, REVOK) always demand an ending, so a bare `MOV` in an
|
||||
* assembly listing is left alone.
|
||||
*
|
||||
* `percent` carries value bands instead of one colour: the number in the match
|
||||
* picks the band (below 20% red, 20–50% yellow, 50–80% light green, 80%+ green),
|
||||
* which is what makes a progress bar readable at a glance.
|
||||
*/
|
||||
export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
|
||||
RULE('perm', '^[-dlbcps][rwxstST-]{9}', 1, '#3fb950', 'Linux 权限与用户'),
|
||||
RULE('path', '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)', 2, '#58a6ff', 'Linux 文件路径'),
|
||||
RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 3, '#d2a8ff', 'Shell 关键字与流程'),
|
||||
RULE('okstate', '\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b', 5, '#3fb950', '成功状态'),
|
||||
RULE('badstate', '\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b', 6, '#f85149', '错误/警告状态'),
|
||||
RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 8, '#e3b341', '字符串与引号'),
|
||||
RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 10, '#79c0ff', '环境变量与参数'),
|
||||
RULE(
|
||||
'danger',
|
||||
'\\brm\\s+-(?:[a-z]*[rf][a-z]*)\\b|\\bmkfs(?:\\.[a-z0-9]+)?\\b|\\bdd\\s+if(?==)|\\bchmod\\s+(?:-R\\s+)?0?777\\b|\\b(?:shutdown|reboot|poweroff|halt)\\b|\\bgit\\s+(?:push\\s+(?:-f|--force)|reset\\s+--hard)\\b|\\bdrop\\s+(?:database|table)\\b|\\|\\s*(?:sudo\\s+)?(?:sh|bash|zsh|dash|ksh|python[0-9.]*|perl|ruby|node)\\b|\\beval\\b|\\bbase64\\s+(?:-d|--decode)\\b|\\bchmod\\s+\\+x\\b',
|
||||
3,
|
||||
'#ff7b72',
|
||||
'危险/破坏性命令',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE(
|
||||
'delop',
|
||||
'\\b(?:DELET(?:E|ED|ES|ING|ION)|REMOV(?:E|ED|ES|ING|AL)|RM|RMDIR|UNLINK(?:ED|S|ING)?|ERAS(?:E|ED|ES|ING)|PURG(?:E|ED|ES|ING)|DESTROY(?:ED|S|ING)?|TRUNCAT(?:E|ED|ES|ING|ION)|WIP(?:E|ED|ES|ING)|DISCARD(?:ED|S|ING)?|REVOK(?:E|ED|ES|ING)|UNMOUNT(?:ED|S|ING)?|KILL(?:ED|S|ING)?|TERMINAT(?:E|ED|ES|ING|ION)|DROP(?:PED|S|PING)?|CLEANUP|SHRED(?:DED|DING)?|MOV(?:E|ED|ES|ING)|RENAM(?:E|ED|ES|ING)|OVERWRIT(?:E|TEN|ING|ES)?)\\b',
|
||||
4,
|
||||
'#ffa657',
|
||||
'删除/移动/覆盖操作',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE(
|
||||
'createop',
|
||||
'\\b(?:CREAT(?:E|ED|ES|ING|ION)|MKDIR|TOUCH(?:ED|ES|ING)?|ADD(?:ED|S|ING)?|INSTALL(?:ED|S|ING)?|CLON(?:E|ED|ES|ING)|GENERAT(?:E|ED|ES|ING|ION)|INIT(?:IALIZ(?:E|ED|ES|ING))?|IMPORT(?:ED|S|ING)?|UPLOAD(?:ED|S|ING)?|DOWNLOAD(?:ED|S|ING)?|SAV(?:E|ED|ES|ING)|WROTE|WRITTEN|MOUNT(?:ED|S|ING)?)\\b',
|
||||
5,
|
||||
'#56d364',
|
||||
'新建/创建操作',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE(
|
||||
'okstate',
|
||||
'(?<!not\\s)\\b(?:SUCCESS(?:FUL(?:LY)?)?|SUCCEED(?:S|ED|ING)?|PASS(?:ED|ES|ING)?|OK(?:AY)?|DONE|FINISH(?:ED|ES|ING)?|COMPLET(?:E|ED|ES|ING|ION)|GOOD|FINE|TRUE|ENABL(?:E|ED|ES|ING)|ACTIVE|ONLINE|READY|HEALTHY|MATCH(?:ED|ES|ING)?|FOUND|ACCEPT(?:ED|S|ING)?|APPROV(?:E|ED|ES|ING|AL)|VALID|ALLOW(?:ED|S|ING)?)\\b|[\\u2713\\u2714\\u2705]\\uFE0F?',
|
||||
6,
|
||||
'#3fb950',
|
||||
'成功状态',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE(
|
||||
'warnstate',
|
||||
'\\b(?:WARN(?:S|ED|ING|INGS)?|CAUTION|ATTENTION|NOTICE|DEPRECAT(?:E|ED|ES|ING|ION)|OBSOLETE|UNSTABLE|TODO|FIXME|XXX|HACK|TBD)\\b|\\u26A0\\uFE0F?',
|
||||
7,
|
||||
'#e3b341',
|
||||
'警告与待办标记',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE(
|
||||
'badstate',
|
||||
'\\b(?:ERROR|ERR|ERRNO|EXCEPTION|TRACEBACK|FAIL(?:ED|S|URE|URES|ING)?|FATAL|CRITICAL|PANIC|ABORT(?:ED|S|ING)?|CRASH(?:ED|ES|ING)?|BUG(?:S|GY)?|SEGFAULT|SEGV|OOM|DENIED|REFUS(?:E|ED|ES|ING|AL)|REJECT(?:ED|S|ING)?|INVALID|ILLEGAL|MISMATCH(?:ED|ES)?|TIMEOUT|TIMED\\s+OUT|UNREACHABLE|UNHEALTHY|CONFLICT(?:ED|S|ING)?|VIOLAT(?:ED|ES|ING|ION)|CORRUPT(?:ED|S|ING|ION)?|MISSING|NOT\\s+(?:OK|DONE|PASSED|FOUND|READY|ACTIVE|RUNNING)|NG|BAD|WRONG|OFFLINE)\\b|[\\u2717\\u2718\\u2716\\u274C]\\uFE0F?',
|
||||
8,
|
||||
'#f85149',
|
||||
'错误/失败状态',
|
||||
{ caseInsensitive: true }
|
||||
),
|
||||
RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 9, '#d2a8ff', 'Shell 关键字与流程'),
|
||||
RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 10, '#e3b341', '字符串与引号'),
|
||||
RULE('loglevel', '\\b(?:INFO|DEBUG|TRACE|VERBOSE)\\b', 11, '#79c0ff', '日志级别'),
|
||||
RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 12, '#79c0ff', '环境变量与参数'),
|
||||
RULE('rootat', '\\broot@[A-Za-z0-9_.-]+', 13, '#ffa657', '管理员提示符 root@'),
|
||||
RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'),
|
||||
RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'),
|
||||
RULE(
|
||||
'exitcode',
|
||||
'\\bexit(?:ed)?\\s+(?:with\\s+)?(?:code|status)\\s*[:=]?\\s*\\d+|\\bexit\\s+\\d{1,3}\\b',
|
||||
21,
|
||||
'#3fb950',
|
||||
'退出码',
|
||||
{
|
||||
bands: [
|
||||
{ min: 0, fg: '#3fb950' },
|
||||
{ min: 1, fg: '#f85149' }
|
||||
]
|
||||
}
|
||||
),
|
||||
RULE(
|
||||
'percent',
|
||||
'\\b\\d{1,3}(?:\\.\\d+)?\\s?%',
|
||||
22,
|
||||
'#3fb950',
|
||||
'百分比与进度',
|
||||
{
|
||||
bands: [
|
||||
{ min: 0, fg: '#f85149' },
|
||||
{ min: 20, fg: '#e3b341' },
|
||||
{ min: 50, fg: '#7ee787' },
|
||||
{ min: 80, fg: '#3fb950' }
|
||||
]
|
||||
}
|
||||
),
|
||||
RULE(
|
||||
'http',
|
||||
'(?<=\\bHTTP/\\d(?:\\.\\d)?\\s)[1-5]\\d\\d\\b|(?<=\\b(?:status|code|http_code|status_code)\\s*[:=]?\\s)[1-5]\\d\\d\\b|\\b[1-5]\\d\\d(?=\\s+(?:OK|Created|Accepted|No Content|Moved Permanently|Found|Not Modified|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Conflict|Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\\b)',
|
||||
23,
|
||||
'#58a6ff',
|
||||
'HTTP 状态码',
|
||||
{
|
||||
bands: [
|
||||
{ min: 100, fg: '#8b949e' },
|
||||
{ min: 200, fg: '#3fb950' },
|
||||
{ min: 300, fg: '#58a6ff' },
|
||||
{ min: 400, fg: '#e3b341' },
|
||||
{ min: 500, fg: '#f85149' }
|
||||
]
|
||||
}
|
||||
),
|
||||
RULE(
|
||||
'latency',
|
||||
'\\b\\d+(?:\\.\\d+)?\\s?(?:ms|msec|µs|us|ns)\\b',
|
||||
24,
|
||||
'#3fb950',
|
||||
'耗时(毫秒)',
|
||||
{
|
||||
bands: [
|
||||
{ min: 0, fg: '#3fb950' },
|
||||
{ min: 100, fg: '#e3b341' },
|
||||
{ min: 500, fg: '#f85149' }
|
||||
]
|
||||
}
|
||||
),
|
||||
RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'),
|
||||
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接')
|
||||
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接'),
|
||||
RULE(
|
||||
'secret',
|
||||
'\\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,})\\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\\bBearer\\s+[A-Za-z0-9._-]{16,}',
|
||||
31,
|
||||
'#ff7b72',
|
||||
'疑似密钥/令牌'
|
||||
)
|
||||
]
|
||||
|
||||
/**
|
||||
* Rules that keep working in the `basic` highlight mode: the cheap, high-signal
|
||||
* ones — failures, successes, warnings, destructive commands, leaked secrets.
|
||||
* Everything else (paths, numbers, URLs, network, timings) is dropped there,
|
||||
* which is what makes that mode both quieter and roughly 3x cheaper.
|
||||
*/
|
||||
const BASIC_RULE_IDS = new Set(['danger', 'secret', 'okstate', 'warnstate', 'badstate'])
|
||||
/**
|
||||
* Category per preset id, for the settings table's grouped view. One table
|
||||
* rather than a field on each rule, so the whole taxonomy reads at a glance —
|
||||
* and so `basic` stays a strict subset of the safety + status groups.
|
||||
*/
|
||||
const PRESET_CATEGORIES: Record<string, HighlightCategory> = {
|
||||
danger: 'safety',
|
||||
secret: 'safety',
|
||||
okstate: 'status',
|
||||
warnstate: 'status',
|
||||
badstate: 'status',
|
||||
loglevel: 'status',
|
||||
exitcode: 'status',
|
||||
rootat: 'status',
|
||||
percent: 'metric',
|
||||
latency: 'metric',
|
||||
numbers: 'metric',
|
||||
ipv4: 'net',
|
||||
url: 'net',
|
||||
http: 'net',
|
||||
perm: 'file',
|
||||
path: 'file',
|
||||
delop: 'file',
|
||||
createop: 'file',
|
||||
shellkw: 'text',
|
||||
quoted: 'text',
|
||||
envvar: 'text',
|
||||
datetime: 'text'
|
||||
}
|
||||
for (const rule of DEFAULT_HIGHLIGHT_RULES) {
|
||||
if (BASIC_RULE_IDS.has(rule.id)) rule.basic = true
|
||||
const category = PRESET_CATEGORIES[rule.id]
|
||||
if (category !== undefined) rule.category = category
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
terminal: {
|
||||
fontFamily: "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace",
|
||||
@@ -137,10 +418,16 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
showOpenLogsButton: false,
|
||||
showOpenCwdButton: true,
|
||||
tabAccentColor: '#3fb950',
|
||||
highlightMode: 'all',
|
||||
highlightGroupByCategory: false,
|
||||
highlightStats: false,
|
||||
highlightThemeColors: false,
|
||||
highlightPerHost: false,
|
||||
backgroundImage: '',
|
||||
backgroundImageOpacity: 60
|
||||
},
|
||||
customThemes: [],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
||||
highlightProfiles: [],
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' }
|
||||
}
|
||||
Reference in new issue
Block a user