fix(highlight): settings UI fixes from review — grouped view, import guard, basic flag
CI / typecheck + test + build (windows) (push) Canceled after 0s
CI / typecheck + test + build (windows) (push) Canceled after 0s
- grouped view actually clusters: drop the priority column's defaultSortOrder that made antd re-sort the dataSource and undo the category clustering - replace import is Popconfirm-guarded and the import mode resets to append each time the dialog opens (it stayed on the destructive choice) - rule editor exposes the basic flag (basic-mode membership) with a switch; clearing it on edit now actually removes the flag - rule/profile writes read the store at call time instead of the render-scoped array, so two writes in one React batch no longer drop the first - profile editor lists the rules a non-empty profile leaves out (uses the previously dead excludedByProfile helper) - bands editor keeps rows sorted by min; band preview keys by index (duplicate min no longer collides); clear-background also closes the bg picker - TerminalView pushes compiled rules into the HighlightStream from an effect instead of during render
This commit is contained in:
1 parent
462da60977
commit
b7c7c5cd76
9 files changed
+127
-28
No files matched your search
@@ -83,7 +83,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
|||||||
- 跟随主题(`settings.terminal.highlightThemeColors`,默认关):`src/renderer/src/theme/highlightColors.ts` 按**色相分桶**把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;**背景色不映射**(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致
|
- 跟随主题(`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 格式化)
|
- 统计(`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`
|
- 性能护栏(别拆):`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.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()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失
|
- 按主机绑定规则集(`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 每敲一个键重渲染会卡
|
- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
|
||||||
- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?<!not\s)` 反向断言,把 `not ok` 让给 `badstate`
|
- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?<!not\s)` 反向断言,把 `not ok` 让给 `badstate`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
|
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
|
||||||
import { t } from '@shared/i18n'
|
import { t } from '@shared/i18n'
|
||||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
|
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
|
||||||
import type { ImportError } from '@shared/highlightIO'
|
import type { ImportError } from '@shared/highlightIO'
|
||||||
@@ -40,6 +40,7 @@ export function HighlightImportExport({
|
|||||||
setDraft('')
|
setDraft('')
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setCopied(false)
|
setCopied(false)
|
||||||
|
setMode('append')
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleCopy = async (): Promise<void> => {
|
const handleCopy = async (): Promise<void> => {
|
||||||
@@ -83,7 +84,9 @@ export function HighlightImportExport({
|
|||||||
setResult({ warnings: parsed.warnings, count: 0 })
|
setResult({ warnings: parsed.warnings, count: 0 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
|
// read at call time: the render-scoped array goes stale inside a React batch
|
||||||
|
const current = useSettingsStore.getState().settings.highlightRules
|
||||||
|
await setHighlightRules(mergeRules(current, parsed.rules, mode))
|
||||||
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
|
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,14 +148,30 @@ export function HighlightImportExport({
|
|||||||
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
|
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
|
||||||
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
|
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
<Button
|
{mode === 'replace' ? (
|
||||||
size="small"
|
// replace wipes the whole set, so it gets the same guard as deletes
|
||||||
type="primary"
|
<Popconfirm
|
||||||
disabled={draft.trim() === ''}
|
title={t('settings.highlight.importReplaceTitle')}
|
||||||
onClick={() => void handleImport()}
|
description={t('settings.highlight.importReplaceDesc')}
|
||||||
>
|
okText={t('settings.highlight.importOk')}
|
||||||
{t('settings.highlight.importOk')}
|
cancelText={t('common.cancel')}
|
||||||
</Button>
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void handleImport()}
|
||||||
|
>
|
||||||
|
<Button size="small" type="primary" disabled={draft.trim() === ''}>
|
||||||
|
{t('settings.highlight.importOk')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
disabled={draft.trim() === ''}
|
||||||
|
onClick={() => void handleImport()}
|
||||||
|
>
|
||||||
|
{t('settings.highlight.importOk')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
{result && (
|
{result && (
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
|
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { t } from '@shared/i18n'
|
import { t } from '@shared/i18n'
|
||||||
|
import { excludedByProfile } from '@shared/highlightProfiles'
|
||||||
import type { HighlightProfile } from '@shared/settings'
|
import type { HighlightProfile } from '@shared/settings'
|
||||||
import { ruleLabel } from './ruleLabel'
|
import { ruleLabel } from './ruleLabel'
|
||||||
import { useSettingsStore } from './store'
|
import { useSettingsStore } from './store'
|
||||||
@@ -39,11 +40,15 @@ export function HighlightProfiles(): React.JSX.Element {
|
|||||||
if (name.trim() === '') return
|
if (name.trim() === '') return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const next = editing
|
// read at call time: the render-scoped array goes stale inside a React
|
||||||
? profiles.map((profile) =>
|
// batch, and two writes in one batch would silently drop the first
|
||||||
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
|
const current = useSettingsStore.getState().settings.highlightProfiles
|
||||||
|
const target = editingId !== null && editingId !== '' ? current.find((profile) => profile.id === editingId) : undefined
|
||||||
|
const next = target
|
||||||
|
? current.map((profile) =>
|
||||||
|
profile.id === target.id ? { ...profile, name: name.trim(), ruleIds } : profile
|
||||||
)
|
)
|
||||||
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
|
: [...current, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
|
||||||
await setHighlightProfiles(next)
|
await setHighlightProfiles(next)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -51,6 +56,16 @@ export function HighlightProfiles(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the current selection leaves out, shown under the checkboxes so the
|
||||||
|
* user sees which rules a host bound to this profile will NOT run. An empty
|
||||||
|
* selection means "all rules", so nothing is excluded by definition.
|
||||||
|
*/
|
||||||
|
const excluded =
|
||||||
|
ruleIds.length > 0 && ruleIds.length < rules.length
|
||||||
|
? excludedByProfile(rules, { id: editingId ?? '', name, ruleIds })
|
||||||
|
: []
|
||||||
|
|
||||||
const columns: ColumnsType<HighlightProfile> = [
|
const columns: ColumnsType<HighlightProfile> = [
|
||||||
{
|
{
|
||||||
title: t('settings.highlight.profileName'),
|
title: t('settings.highlight.profileName'),
|
||||||
@@ -84,7 +99,11 @@ export function HighlightProfiles(): React.JSX.Element {
|
|||||||
okText={t('common.delete')}
|
okText={t('common.delete')}
|
||||||
cancelText={t('common.cancel')}
|
cancelText={t('common.cancel')}
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
|
onConfirm={() =>
|
||||||
|
void setHighlightProfiles(
|
||||||
|
useSettingsStore.getState().settings.highlightProfiles.filter((p) => p.id !== record.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Button size="small" danger>
|
<Button size="small" danger>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
@@ -154,6 +173,14 @@ export function HighlightProfiles(): React.JSX.Element {
|
|||||||
onChange={(values) => setRuleIds(values as string[])}
|
onChange={(values) => setRuleIds(values as string[])}
|
||||||
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
|
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
|
||||||
/>
|
/>
|
||||||
|
{excluded.length > 0 && (
|
||||||
|
<span className="hl-editor-hint">
|
||||||
|
{t('settings.highlight.profileExcluded', {
|
||||||
|
n: excluded.length,
|
||||||
|
list: excluded.map((rule) => ruleLabel(rule)).join('; ')
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ import {
|
|||||||
import { applyThemeColors } from '../theme/highlightColors'
|
import { applyThemeColors } from '../theme/highlightColors'
|
||||||
import './highlight.css'
|
import './highlight.css'
|
||||||
|
|
||||||
const ORDERED_SORT: 'ascend' = 'ascend'
|
|
||||||
|
|
||||||
/** preset color swatches for the highlighting editor. */
|
/** preset color swatches for the highlighting editor. */
|
||||||
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
|
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
|
||||||
|
|
||||||
@@ -92,6 +90,7 @@ interface RuleDraft {
|
|||||||
caseInsensitive: boolean
|
caseInsensitive: boolean
|
||||||
note?: string
|
note?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
basic: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
@@ -115,6 +114,8 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
|
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
|
||||||
// Grouped view: cluster by category, keeping priority order inside a group
|
// Grouped view: cluster by category, keeping priority order inside a group
|
||||||
// (Array.prototype.sort is stable, so the priority sort above survives).
|
// (Array.prototype.sort is stable, so the priority sort above survives).
|
||||||
|
// The table must not carry a defaultSortOrder — antd would re-sort the
|
||||||
|
// dataSource globally and undo this clustering.
|
||||||
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
|
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
|
||||||
}, [highlightRules, groupByCategory])
|
}, [highlightRules, groupByCategory])
|
||||||
|
|
||||||
@@ -123,7 +124,10 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
|
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
|
||||||
|
|
||||||
const replaceRule = async (updated: HighlightRule): Promise<void> => {
|
const replaceRule = async (updated: HighlightRule): Promise<void> => {
|
||||||
const next = highlightRules.map((r) => (r.id === updated.id ? updated : r))
|
// read at call time: the render-scoped array goes stale inside a React
|
||||||
|
// batch, and two writes in one batch would silently drop the first
|
||||||
|
const current = useSettingsStore.getState().settings.highlightRules
|
||||||
|
const next = current.map((r) => (r.id === updated.id ? updated : r))
|
||||||
await setHighlightRules(next)
|
await setHighlightRules(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +136,8 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string): Promise<void> => {
|
const handleDelete = async (id: string): Promise<void> => {
|
||||||
await setHighlightRules(highlightRules.filter((r) => r.id !== id))
|
const current = useSettingsStore.getState().settings.highlightRules
|
||||||
|
await setHighlightRules(current.filter((r) => r.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = async (): Promise<void> => {
|
const handleReset = async (): Promise<void> => {
|
||||||
@@ -206,7 +211,6 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
width: 76,
|
width: 76,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
sorter: (a, b) => a.priority - b.priority,
|
sorter: (a, b) => a.priority - b.priority,
|
||||||
defaultSortOrder: ORDERED_SORT,
|
|
||||||
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
|
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,8 +221,8 @@ export function HighlightTab(): React.JSX.Element {
|
|||||||
render: (color: HighlightRule['color'], record) =>
|
render: (color: HighlightRule['color'], record) =>
|
||||||
record.bands && record.bands.length > 0 ? (
|
record.bands && record.bands.length > 0 ? (
|
||||||
<div className="hl-band-preview">
|
<div className="hl-band-preview">
|
||||||
{record.bands.map((band) => (
|
{record.bands.map((band, index) => (
|
||||||
<Tooltip key={band.min} title={`≥ ${band.min}`}>
|
<Tooltip key={index} title={`≥ ${band.min}`}>
|
||||||
<span className="hl-preview-text" style={{ color: band.fg }}>
|
<span className="hl-preview-text" style={{ color: band.fg }}>
|
||||||
≥{band.min}
|
≥{band.min}
|
||||||
</span>
|
</span>
|
||||||
@@ -423,7 +427,8 @@ function HighlightEditor({
|
|||||||
bg: undefined,
|
bg: undefined,
|
||||||
caseInsensitive: false,
|
caseInsensitive: false,
|
||||||
note: undefined,
|
note: undefined,
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
basic: false
|
||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showBg, setShowBg] = useState(false)
|
const [showBg, setShowBg] = useState(false)
|
||||||
@@ -485,9 +490,10 @@ function HighlightEditor({
|
|||||||
const note = editing ? editing.note : undefined
|
const note = editing ? editing.note : undefined
|
||||||
const enabled = editing ? editing.enabled : true
|
const enabled = editing ? editing.enabled : true
|
||||||
const caseInsensitive = editing ? editing.caseInsensitive === true : false
|
const caseInsensitive = editing ? editing.caseInsensitive === true : false
|
||||||
|
const basic = editing ? editing.basic === true : false
|
||||||
const bands = editing?.bands
|
const bands = editing?.bands
|
||||||
const category = editing?.category
|
const category = editing?.category
|
||||||
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
|
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled, basic })
|
||||||
setShowBg(bg != null)
|
setShowBg(bg != null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open])
|
}, [open])
|
||||||
@@ -507,7 +513,12 @@ function HighlightEditor({
|
|||||||
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
|
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
|
||||||
const bands = draft.bands
|
const bands = draft.bands
|
||||||
if (!bands) return
|
if (!bands) return
|
||||||
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
|
const next = bands.map((band, i) => (i === index ? { ...band, ...partial } : band))
|
||||||
|
// the engine applies bands in ascending min order (compileBands sorts), so
|
||||||
|
// keep the editor rows in that same order — a stable sort keeps equal mins
|
||||||
|
// in their entry order
|
||||||
|
next.sort((a, b) => a.min - b.min)
|
||||||
|
patch({ bands: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeBand = (index: number): void => {
|
const removeBand = (index: number): void => {
|
||||||
@@ -535,6 +546,7 @@ function HighlightEditor({
|
|||||||
bands,
|
bands,
|
||||||
category: draft.category,
|
category: draft.category,
|
||||||
caseInsensitive,
|
caseInsensitive,
|
||||||
|
...(draft.basic ? { basic: true as const } : {}),
|
||||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||||
}
|
}
|
||||||
await setHighlightRules([...highlightRules, rule])
|
await setHighlightRules([...highlightRules, rule])
|
||||||
@@ -548,6 +560,9 @@ function HighlightEditor({
|
|||||||
bands,
|
bands,
|
||||||
category: draft.category,
|
category: draft.category,
|
||||||
caseInsensitive,
|
caseInsensitive,
|
||||||
|
// explicit override (not a conditional spread): unchecking must clear
|
||||||
|
// a basic flag the edited rule may already carry
|
||||||
|
basic: draft.basic ? true : undefined,
|
||||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||||
}
|
}
|
||||||
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
|
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
|
||||||
@@ -646,7 +661,13 @@ function HighlightEditor({
|
|||||||
value={draft.bg ?? PRESET_COLORS[0]}
|
value={draft.bg ?? PRESET_COLORS[0]}
|
||||||
onChange={(v) => patch({ bg: v })}
|
onChange={(v) => patch({ bg: v })}
|
||||||
/>
|
/>
|
||||||
<Button size="small" onClick={() => patch({ bg: undefined })}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
patch({ bg: undefined })
|
||||||
|
setShowBg(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('settings.highlight.clearBg')}
|
{t('settings.highlight.clearBg')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -742,6 +763,12 @@ function HighlightEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="hl-editor-row">
|
||||||
|
<span className="hl-editor-label">{t('settings.highlight.basic')}</span>
|
||||||
|
<Switch checked={draft.basic} onChange={(c) => patch({ basic: c })} />
|
||||||
|
<span className="hl-editor-hint">{t('settings.highlight.basicHint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hl-editor-row">
|
<div className="hl-editor-row">
|
||||||
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
|
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
|
||||||
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
|
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
|
||||||
|
|||||||
@@ -386,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
|
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [settings.terminal.highlightStats])
|
}, [settings.terminal.highlightStats])
|
||||||
streamRef.current?.setRules(compiledRules)
|
// Push the latest compiled rules into the stream from an effect — assigning
|
||||||
|
// during render would be a side effect in the render body.
|
||||||
|
useEffect(() => {
|
||||||
|
streamRef.current?.setRules(compiledRules)
|
||||||
|
}, [compiledRules])
|
||||||
|
|
||||||
// M5: load completion candidates once per session (history + library cached).
|
// M5: load completion candidates once per session (history + library cached).
|
||||||
const ensureCompletionCache = useCallback((): void => {
|
const ensureCompletionCache = useCallback((): void => {
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.importMode': 'Mode',
|
'settings.highlight.importMode': 'Mode',
|
||||||
'settings.highlight.importReplace': 'Replace current rules',
|
'settings.highlight.importReplace': 'Replace current rules',
|
||||||
'settings.highlight.importAppend': 'Append to current rules',
|
'settings.highlight.importAppend': 'Append to current rules',
|
||||||
|
'settings.highlight.importReplaceTitle': 'Replace current rules?',
|
||||||
|
'settings.highlight.importReplaceDesc':
|
||||||
|
'Importing overwrites every current rule with this file. This cannot be undone.',
|
||||||
'settings.highlight.importOk': 'Import',
|
'settings.highlight.importOk': 'Import',
|
||||||
'settings.highlight.importEmpty': 'No usable rules found',
|
'settings.highlight.importEmpty': 'No usable rules found',
|
||||||
'settings.highlight.importBadJson': 'Not valid JSON',
|
'settings.highlight.importBadJson': 'Not valid JSON',
|
||||||
@@ -214,6 +217,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.modeOff': 'Off',
|
'settings.highlight.modeOff': 'Off',
|
||||||
'settings.highlight.modeDesc':
|
'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).',
|
'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.basic': 'Basic rule',
|
||||||
|
'settings.highlight.basicHint': 'keep this rule active when the highlight mode is "Basic only"',
|
||||||
'settings.highlight.category': 'Category',
|
'settings.highlight.category': 'Category',
|
||||||
'settings.highlight.groupByCategory': 'Group by category',
|
'settings.highlight.groupByCategory': 'Group by category',
|
||||||
'settings.highlight.category.safety': 'Safety',
|
'settings.highlight.category.safety': 'Safety',
|
||||||
@@ -245,6 +250,7 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
|
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
|
||||||
'settings.highlight.profileRules': 'Included rules',
|
'settings.highlight.profileRules': 'Included rules',
|
||||||
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
|
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
|
||||||
|
'settings.highlight.profileExcluded': 'Excluded {n}: {list}',
|
||||||
'settings.highlight.profileEmpty': 'No rule sets yet',
|
'settings.highlight.profileEmpty': 'No rule sets yet',
|
||||||
'settings.highlight.profileDeleteTitle': 'Delete rule set',
|
'settings.highlight.profileDeleteTitle': 'Delete rule set',
|
||||||
'settings.highlight.actions': 'Actions',
|
'settings.highlight.actions': 'Actions',
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.importMode': '取り込み方法',
|
'settings.highlight.importMode': '取り込み方法',
|
||||||
'settings.highlight.importReplace': '現在のルールを置き換える',
|
'settings.highlight.importReplace': '現在のルールを置き換える',
|
||||||
'settings.highlight.importAppend': '現在のルールに追加する',
|
'settings.highlight.importAppend': '現在のルールに追加する',
|
||||||
|
'settings.highlight.importReplaceTitle': '現在のルールを置き換えますか?',
|
||||||
|
'settings.highlight.importReplaceDesc':
|
||||||
|
'インポートすると現在のすべてのルールがこのファイルの内容で上書きされます。元に戻せません。',
|
||||||
'settings.highlight.importOk': 'インポート',
|
'settings.highlight.importOk': 'インポート',
|
||||||
'settings.highlight.importEmpty': '有効なルールが見つかりません',
|
'settings.highlight.importEmpty': '有効なルールが見つかりません',
|
||||||
'settings.highlight.importBadJson': '有効な JSON ではありません',
|
'settings.highlight.importBadJson': '有効な JSON ではありません',
|
||||||
@@ -210,6 +213,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.modeOff': 'オフ',
|
'settings.highlight.modeOff': 'オフ',
|
||||||
'settings.highlight.modeDesc':
|
'settings.highlight.modeDesc':
|
||||||
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
|
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
|
||||||
|
'settings.highlight.basic': '基本ルール',
|
||||||
|
'settings.highlight.basicHint': 'オンにすると、ハイライトが「基本のみ」モードのときもこのルールが適用されます',
|
||||||
'settings.highlight.category': '分類',
|
'settings.highlight.category': '分類',
|
||||||
'settings.highlight.groupByCategory': '分類でグループ化',
|
'settings.highlight.groupByCategory': '分類でグループ化',
|
||||||
'settings.highlight.category.safety': '安全',
|
'settings.highlight.category.safety': '安全',
|
||||||
@@ -240,6 +245,7 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
|
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
|
||||||
'settings.highlight.profileRules': '含めるルール',
|
'settings.highlight.profileRules': '含めるルール',
|
||||||
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
|
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
|
||||||
|
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
|
||||||
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
|
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
|
||||||
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
|
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
|
||||||
'settings.highlight.actions': '操作',
|
'settings.highlight.actions': '操作',
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.importMode': '导入方式',
|
'settings.highlight.importMode': '导入方式',
|
||||||
'settings.highlight.importReplace': '替换现有规则',
|
'settings.highlight.importReplace': '替换现有规则',
|
||||||
'settings.highlight.importAppend': '追加到现有规则',
|
'settings.highlight.importAppend': '追加到现有规则',
|
||||||
|
'settings.highlight.importReplaceTitle': '替换现有规则?',
|
||||||
|
'settings.highlight.importReplaceDesc': '导入后当前全部规则将被这份文件覆盖,无法恢复。',
|
||||||
'settings.highlight.importOk': '导入',
|
'settings.highlight.importOk': '导入',
|
||||||
'settings.highlight.importEmpty': '没有解析到可用规则',
|
'settings.highlight.importEmpty': '没有解析到可用规则',
|
||||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||||
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.modeBasic': '仅基础',
|
'settings.highlight.modeBasic': '仅基础',
|
||||||
'settings.highlight.modeOff': '关闭',
|
'settings.highlight.modeOff': '关闭',
|
||||||
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
|
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
|
||||||
|
'settings.highlight.basic': '基础规则',
|
||||||
|
'settings.highlight.basicHint': '开启后,终端高亮为「仅基础」模式时该规则仍会生效',
|
||||||
'settings.highlight.category': '分类',
|
'settings.highlight.category': '分类',
|
||||||
'settings.highlight.groupByCategory': '按分类分组',
|
'settings.highlight.groupByCategory': '按分类分组',
|
||||||
'settings.highlight.category.safety': '安全',
|
'settings.highlight.category.safety': '安全',
|
||||||
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
|
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
|
||||||
'settings.highlight.profileRules': '包含的规则',
|
'settings.highlight.profileRules': '包含的规则',
|
||||||
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
|
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
|
||||||
|
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
|
||||||
'settings.highlight.profileEmpty': '尚未创建规则集',
|
'settings.highlight.profileEmpty': '尚未创建规则集',
|
||||||
'settings.highlight.profileDeleteTitle': '删除规则集',
|
'settings.highlight.profileDeleteTitle': '删除规则集',
|
||||||
'settings.highlight.actions': '操作',
|
'settings.highlight.actions': '操作',
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.importMode': '匯入方式',
|
'settings.highlight.importMode': '匯入方式',
|
||||||
'settings.highlight.importReplace': '取代現有規則',
|
'settings.highlight.importReplace': '取代現有規則',
|
||||||
'settings.highlight.importAppend': '附加到現有規則',
|
'settings.highlight.importAppend': '附加到現有規則',
|
||||||
|
'settings.highlight.importReplaceTitle': '取代現有規則?',
|
||||||
|
'settings.highlight.importReplaceDesc': '匯入後目前全部規則將被這份檔案覆蓋,無法復原。',
|
||||||
'settings.highlight.importOk': '匯入',
|
'settings.highlight.importOk': '匯入',
|
||||||
'settings.highlight.importEmpty': '沒有解析到可用規則',
|
'settings.highlight.importEmpty': '沒有解析到可用規則',
|
||||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||||
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.modeBasic': '僅基礎',
|
'settings.highlight.modeBasic': '僅基礎',
|
||||||
'settings.highlight.modeOff': '關閉',
|
'settings.highlight.modeOff': '關閉',
|
||||||
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
|
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
|
||||||
|
'settings.highlight.basic': '基礎規則',
|
||||||
|
'settings.highlight.basicHint': '開啟後,終端機高亮為「僅基礎」模式時該規則仍會生效',
|
||||||
'settings.highlight.category': '分類',
|
'settings.highlight.category': '分類',
|
||||||
'settings.highlight.groupByCategory': '依分類分組',
|
'settings.highlight.groupByCategory': '依分類分組',
|
||||||
'settings.highlight.category.safety': '安全',
|
'settings.highlight.category.safety': '安全',
|
||||||
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
|
|||||||
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
|
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
|
||||||
'settings.highlight.profileRules': '包含的規則',
|
'settings.highlight.profileRules': '包含的規則',
|
||||||
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
|
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
|
||||||
|
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
|
||||||
'settings.highlight.profileEmpty': '尚未建立規則集',
|
'settings.highlight.profileEmpty': '尚未建立規則集',
|
||||||
'settings.highlight.profileDeleteTitle': '刪除規則集',
|
'settings.highlight.profileDeleteTitle': '刪除規則集',
|
||||||
'settings.highlight.actions': '操作',
|
'settings.highlight.actions': '操作',
|
||||||
|
|||||||
Reference in new issue
Block a user