diff --git a/.gitignore b/.gitignore index a4647fe..9126426 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ tests/.commands-store.cjs tests/.settings-store.cjs tests/.session-e2e.cjs tests/.hl-split-smoke.cjs +tests/.hl-rules.cjs tests/.zmodem-e2e.cjs release/ diff --git a/AGENTS.md b/AGENTS.md index 4c5310e..57fa24a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,26 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧 - 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效 + +## 关键词高亮 + +- 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过** +- 状态类预设(danger/okstate/warnstate/badstate)priority 排在 `shellkw` **之前**:`done` 既是 shell 关键字又是成功词、`if` 还在 `dd if=` 里,先跑谁就由谁着色 +- 状态符号(`✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠`)**不能放进 `\b…\b` 组**:`\b✓` 永不成立。预设把它们写在 `\b(?:…)\b|[✓✔✅]\uFE0F?` 的第二个分支里,尾随的 `\uFE0F?` 是为了把 emoji 变体选择符一起圈进着色范围 +- 严重级别按颜色拆开:成功(okstate 绿)/ 警告(warnstate 黄)/ 错误·致命(badstate 红,含 `CRITICAL`、`FATAL`、`PANIC`)/ 删除·移动·覆盖(delop 橙)/ 新建·创建·安装(createop 亮绿);`badstate` 里的 `NOT …` 分支负责 `not ok` / `not found`,`okstate` 的反向断言保证它不会被染绿 +- delop/createop 只列**操作动词**(delete/remove/rm/mkdir/touch/add/install/clone/…),`export` 等 shell 关键字仍归 `shellkw`,别把两边都写进去抢 span +- **数值分级(`bands`)**:匹配里的第一个数字决定颜色(最后一个 `min <= 值` 的分级胜出),无数字或低于最小分级时回落到 `color.fg`;预设 `percent` 用它做百分比(<20% 红 / 20–50% 黄 / 50–80% 浅绿 / ≥80% 绿)。它的 priority 22 **必须早于 `numbers`(25)**,否则 `45%` 会先被数字规则整段吃掉;`numbers` 规则本身不含百分比分支 +- 词干要自带词尾(`DELET(?:E|ED|ES|ING|ION)` 而非 `DELETE(?:D|S|ING)?`,否则漏 `deleting`);不成词的词干(MOV/SAV/CLON/PURG/ERAS/WIP/REVOK)必须强制要求词尾 +- 上下文敏感的规则用 **lookbehind/lookahead 只圈住关键词本身**,否则分级会读到错误的数字:`(?<=\bHTTP/\d(?:\.\d)?\s)[1-5]\d\d\b` 让 `HTTP/1.1 404` 只着色 `404`(若把 `HTTP/1.1` 一起匹配,bands 会读到版本号 `1`)。同理 `[1-5]\d\d(?=\s+OK|…)` 靠先行断言限定"后面跟原因短语"才算状态码,避免把 `123` 这类普通数字当成 404 +- 裸 3 位数(`[1-5]\d\d`)**不能单独成规则**,必须有上下文锚点;同理 MAC、短哈希这类高误伤模式不进预设 +- 导入 / 导出与实时预览:`src/shared/highlightIO.ts` 管 JSON 信封(`kind`/`version`,外来 JSON 直接拒绝)与 replace/append 合并;渲染层用剪贴板 + FileReader + Blob 下载完成,**不新增 IPC**。编辑器预览走引擎的 `previewSpans`(跑真实高亮再解析它自己的 SGR 输出),并且**带上当前其它规则**,这样 span 被别的规则抢走时能一眼看出来 +- 分类(`category`,`safety|status|file|net|text|metric`):预设的分类放在 `PRESET_CATEGORIES` 一张表里,`basic` 集合必须始终是 safety+status 的子集(有测试守);`settings.terminal.highlightGroupByCategory` 只影响设置页(加分类列 + 按分类聚簇,组内仍按 priority),**热路径完全不涉及** +- 跟随主题(`settings.terminal.highlightThemeColors`,默认关):`src/renderer/src/theme/highlightColors.ts` 按**色相分桶**把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;**背景色不映射**(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致 +- 统计(`settings.terminal.highlightStats`,默认关):引擎的 `applyHighlights`/`HighlightStream` 接受可选 `StatsSink`,**只在传了 sink 时才计数与计时**(默认路径不插桩);`TerminalView` 只在开关打开时挂 sink,并**每秒发布一次快照**(不是每块),否则忙碌的终端会把设置页重渲染到卡死;设置页通过 `subscribeHighlightStats` 订阅,多出「命中/耗时」两列(耗时按 µs/ms 格式化) +- 性能护栏(别拆):`MAX_CHUNK` 512KB 整块跳过、`MAX_LINE_LEN` 4KB 超长行不跑规则(挡 `(a+)+b` 这类回溯)、`MAX_PER_RULE` 300 每条规则每块上限。实测 180–200KB 混合输出:**全预设 4–9ms/块**(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、**basic 档 ~1.3ms/块(≈49µs/chunk)**、**off 档 0**;单条规则最贵的是 `http`(0.82ms)、`percent`、`danger` +- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(danger/secret/okstate/warnstate/badstate 这 5 条),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本 +- 按主机绑定规则集(`settings.terminal.highlightPerHost`,默认关):`HighlightProfile { id, name, ruleIds }` 存在 `AppSettings.highlightProfiles`(顶层数组,仿 `customThemes`),清洗在 `src/shared/highlightProfiles.ts`;`ruleIds` **为空 = 全部规则**,`rulesForProfile` 对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,`excludedByProfile` 给设置页算「这个 profile 排除了哪些规则」。绑定存在 **`connections.json`**(`SshConnection.highlightProfileId`)而不是 settings,`TerminalView` 只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:`PUBLIC_KEYS`(update 路径靠它回写)、`toPublic()`、`saveConnection()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失 +- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡 +- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?)[] 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) diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index f6145ff..b20fb4a 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -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) : 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 = { + 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 const merged: Record = { ...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 } } } diff --git a/src/renderer/src/connections/Common.tsx b/src/renderer/src/connections/Common.tsx index f27a289..49a3f20 100644 --- a/src/renderer/src/connections/Common.tsx +++ b/src/renderer/src/connections/Common.tsx @@ -57,6 +57,10 @@ export function buildInput(values: Record, 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, ctx: BuildCtx): SshC askPasswordAtConnect, askPassphraseAtConnect, keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30, - group + group, + highlightProfileId } const secret = (key: SecretKind): string | undefined => { diff --git a/src/renderer/src/connections/ConnectionEditDialog.tsx b/src/renderer/src/connections/ConnectionEditDialog.tsx index f04272b..062dd36 100644 --- a/src/renderer/src/connections/ConnectionEditDialog.tsx +++ b/src/renderer/src/connections/ConnectionEditDialog.tsx @@ -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({ > + + + { + const file = e.target.files?.[0] + if (file) handleFile(file) + e.target.value = '' + }} + /> + + + {t('settings.highlight.importMode')} + setMode(e.target.value as 'replace' | 'append')} + > + {t('settings.highlight.importAppend')} + {t('settings.highlight.importReplace')} + + + + {result && ( + 0 + ? t('settings.highlight.importSkipped', { + n: result.warnings.length, + list: result.warnings.join('; ') + }) + : undefined + } + /> + )} + + + + ) +} diff --git a/src/renderer/src/settings/HighlightProfiles.tsx b/src/renderer/src/settings/HighlightProfiles.tsx new file mode 100644 index 0000000..ca78bc5 --- /dev/null +++ b/src/renderer/src/settings/HighlightProfiles.tsx @@ -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(null) + const [name, setName] = useState('') + const [ruleIds, setRuleIds] = useState([]) + 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 => { + 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 = [ + { + title: t('settings.highlight.profileName'), + dataIndex: 'name', + ellipsis: true + }, + { + title: t('settings.highlight.profileRules'), + dataIndex: 'ruleIds', + width: 120, + align: 'center', + render: (ids: string[]) => ( + + {ids.length === 0 ? t('settings.highlight.modeAll') : ids.length} + + ) + }, + { + title: t('settings.highlight.actions'), + key: 'action', + width: 120, + align: 'center', + render: (_value, record) => ( +
+ + void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))} + > + + +
+ ) + } + ] + + return ( +
+
+ + + void updateTerminal({ highlightPerHost: checked })} + /> + {t('settings.highlight.perHost')} + + + +
+ {t('settings.highlight.profilesHint')} + + + 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() === '' }} + > +
+
+ {t('settings.highlight.profileName')} + setName(e.target.value)} + placeholder={t('settings.highlight.profileNamePlaceholder')} + maxLength={40} + /> +
+
+ {t('settings.highlight.profileRules')} +
+ {t('settings.highlight.profileRulesHint')} + setRuleIds(values as string[])} + options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))} + /> +
+
+
+
+ + ) +} diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx index f52cb28..d2ccced 100644 --- a/src/renderer/src/settings/HighlightTab.tsx +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -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.` 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({ 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[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) => {categoryLabel(record.category)} + } + + 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[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) => ( + {ruleStats.get(record.id)?.hits ?? 0} + ) + } + const msColumn: ColumnsType[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) => ( + {formatMs(ruleStats.get(record.id)?.ms ?? 0)} + ) + } + const columns: ColumnsType = [ + ...(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) => + render: (_pattern: string, 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']) => ( - - Highlight - - ) + render: (color: HighlightRule['color'], record) => + record.bands && record.bands.length > 0 ? ( +
+ {record.bands.map((band) => ( + + + ≥{band.min} + + + ))} +
+ ) : ( + + Highlight + + ) }, { title: t('settings.highlight.note'), @@ -180,6 +282,57 @@ export function HighlightTab(): React.JSX.Element { message={t('settings.highlight.alert')} />
+ + + {t('settings.highlight.mode')} + 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') } + ]} + /> + + + + + void updateTerminal({ highlightGroupByCategory: checked })} + /> + {t('settings.highlight.groupByCategory')} + + + + + void updateTerminal({ highlightStats: checked })} + /> + {t('settings.highlight.stats')} + + + {statsOn && ( + + )} + + + void updateTerminal({ highlightThemeColors: checked })} + /> + {t('settings.highlight.themeColors')} + + +
-
+ setIoOpen(false)} /> + +
- - {pattern} + {rule.caseInsensitive === true && ( + + Aa + + )} + + {rule.pattern} {!ok && ( @@ -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): 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 => { 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 && (
{compile.message}
)} +
+ patch({ caseInsensitive: c })} + /> + {t('settings.highlight.caseInsensitive')} + {t('settings.highlight.caseInsensitiveHint')} +
+ + + +
+ {t('settings.highlight.testText')} +
+ setTestText(e.target.value)} + placeholder={SAMPLE_TEXT} + autoSize={{ minRows: 1, maxRows: 3 }} + /> +
+ {preview.map((span, index) => ( + + {span.text} + + ))} +
+ {t('settings.highlight.testHint')}
@@ -388,6 +655,48 @@ function HighlightEditor({ +
+ {t('settings.highlight.bands')} +
+
+ + patch({ bands: c ? (draft.bands ?? [{ min: 0, fg: draft.fg }]) : undefined }) + } + /> + {t('settings.highlight.bandsHint')} +
+ {draft.bands?.map((band, index) => ( +
+ {t('settings.highlight.bandMin')} + updateBand(index, { min: typeof v === 'number' ? v : 0 })} + style={{ width: 88 }} + /> + updateBand(index, { fg: v })} /> + +
+ ))} + {draft.bands != null && ( +
+ +
+ )} +
+
+
{t('settings.highlight.palette')}
@@ -413,6 +722,22 @@ function HighlightEditor({ />
+
+ {t('settings.highlight.category')} +