fix(highlight): settings UI fixes from review — grouped view, import guard, basic flag
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:
Bill committed 2026-09-28 13:06:40 +08:00
1 parent 462da60977
commit b7c7c5cd76
9 files changed
+127 -28

No files matched your search

@@ -1,5 +1,5 @@
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 { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
@@ -40,6 +40,7 @@ export function HighlightImportExport({
setDraft('')
setResult(null)
setCopied(false)
setMode('append')
}, [open])
const handleCopy = async (): Promise<void> => {
@@ -83,7 +84,9 @@ export function HighlightImportExport({
setResult({ warnings: parsed.warnings, count: 0 })
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 })
}
@@ -145,14 +148,30 @@ export function HighlightImportExport({
<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>
{mode === 'replace' ? (
// replace wipes the whole set, so it gets the same guard as deletes
<Popconfirm
title={t('settings.highlight.importReplaceTitle')}
description={t('settings.highlight.importReplaceDesc')}
okText={t('settings.highlight.importOk')}
cancelText={t('common.cancel')}
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>
{result && (
<Alert
@@ -2,6 +2,7 @@ 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 { excludedByProfile } from '@shared/highlightProfiles'
import type { HighlightProfile } from '@shared/settings'
import { ruleLabel } from './ruleLabel'
import { useSettingsStore } from './store'
@@ -39,11 +40,15 @@ export function HighlightProfiles(): React.JSX.Element {
if (name.trim() === '') return
setSaving(true)
try {
const next = editing
? profiles.map((profile) =>
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
// 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.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)
setEditingId(null)
} 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> = [
{
title: t('settings.highlight.profileName'),
@@ -84,7 +99,11 @@ export function HighlightProfiles(): React.JSX.Element {
okText={t('common.delete')}
cancelText={t('common.cancel')}
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>
{t('common.delete')}
@@ -154,6 +173,14 @@ export function HighlightProfiles(): React.JSX.Element {
onChange={(values) => setRuleIds(values as string[])}
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>
+38 -11
View File
@@ -35,8 +35,6 @@ import {
import { applyThemeColors } from '../theme/highlightColors'
import './highlight.css'
const ORDERED_SORT: 'ascend' = 'ascend'
/** preset color swatches for the highlighting editor. */
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
@@ -92,6 +90,7 @@ interface RuleDraft {
caseInsensitive: boolean
note?: string
enabled: boolean
basic: boolean
}
interface EditorState {
@@ -115,6 +114,8 @@ export function HighlightTab(): React.JSX.Element {
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).
// 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
}, [highlightRules, groupByCategory])
@@ -123,7 +124,10 @@ export function HighlightTab(): React.JSX.Element {
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
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)
}
@@ -132,7 +136,8 @@ export function HighlightTab(): React.JSX.Element {
}
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> => {
@@ -206,7 +211,6 @@ export function HighlightTab(): React.JSX.Element {
width: 76,
align: 'center',
sorter: (a, b) => a.priority - b.priority,
defaultSortOrder: ORDERED_SORT,
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) =>
record.bands && record.bands.length > 0 ? (
<div className="hl-band-preview">
{record.bands.map((band) => (
<Tooltip key={band.min} title={`≥ ${band.min}`}>
{record.bands.map((band, index) => (
<Tooltip key={index} title={`≥ ${band.min}`}>
<span className="hl-preview-text" style={{ color: band.fg }}>
≥{band.min}
</span>
@@ -423,7 +427,8 @@ function HighlightEditor({
bg: undefined,
caseInsensitive: false,
note: undefined,
enabled: true
enabled: true,
basic: false
})
const [saving, setSaving] = useState(false)
const [showBg, setShowBg] = useState(false)
@@ -485,9 +490,10 @@ function HighlightEditor({
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const basic = editing ? editing.basic === true : false
const bands = editing?.bands
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)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -507,7 +513,12 @@ function HighlightEditor({
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 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 => {
@@ -535,6 +546,7 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
@@ -548,6 +560,9 @@ function HighlightEditor({
bands,
category: draft.category,
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
}
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
@@ -646,7 +661,13 @@ function HighlightEditor({
value={draft.bg ?? PRESET_COLORS[0]}
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')}
</Button>
</>
@@ -742,6 +763,12 @@ function HighlightEditor({
/>
</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">
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
+5 -1
View File
@@ -386,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer)
}, [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).
const ensureCompletionCache = useCallback((): void => {
+6
View File
@@ -200,6 +200,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': 'Mode',
'settings.highlight.importReplace': 'Replace 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.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON',
@@ -214,6 +217,8 @@ const settings: Record<string, string> = {
'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.basic': 'Basic rule',
'settings.highlight.basicHint': 'keep this rule active when the highlight mode is "Basic only"',
'settings.highlight.category': 'Category',
'settings.highlight.groupByCategory': 'Group by category',
'settings.highlight.category.safety': 'Safety',
@@ -245,6 +250,7 @@ const settings: Record<string, string> = {
'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.profileExcluded': 'Excluded {n}: {list}',
'settings.highlight.profileEmpty': 'No rule sets yet',
'settings.highlight.profileDeleteTitle': 'Delete rule set',
'settings.highlight.actions': 'Actions',
+6
View File
@@ -196,6 +196,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '取り込み方法',
'settings.highlight.importReplace': '現在のルールを置き換える',
'settings.highlight.importAppend': '現在のルールに追加する',
'settings.highlight.importReplaceTitle': '現在のルールを置き換えますか?',
'settings.highlight.importReplaceDesc':
'インポートすると現在のすべてのルールがこのファイルの内容で上書きされます。元に戻せません。',
'settings.highlight.importOk': 'インポート',
'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません',
@@ -210,6 +213,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeOff': 'オフ',
'settings.highlight.modeDesc':
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
'settings.highlight.basic': '基本ルール',
'settings.highlight.basicHint': 'オンにすると、ハイライトが「基本のみ」モードのときもこのルールが適用されます',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '分類でグループ化',
'settings.highlight.category.safety': '安全',
@@ -240,6 +245,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
'settings.highlight.profileRules': '含めるルール',
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
'settings.highlight.actions': '操作',
+5
View File
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '导入方式',
'settings.highlight.importReplace': '替换现有规则',
'settings.highlight.importAppend': '追加到现有规则',
'settings.highlight.importReplaceTitle': '替换现有规则?',
'settings.highlight.importReplaceDesc': '导入后当前全部规则将被这份文件覆盖,无法恢复。',
'settings.highlight.importOk': '导入',
'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '仅基础',
'settings.highlight.modeOff': '关闭',
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
'settings.highlight.basic': '基础规则',
'settings.highlight.basicHint': '开启后,终端高亮为「仅基础」模式时该规则仍会生效',
'settings.highlight.category': '分类',
'settings.highlight.groupByCategory': '按分类分组',
'settings.highlight.category.safety': '安全',
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
'settings.highlight.profileRules': '包含的规则',
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
'settings.highlight.profileEmpty': '尚未创建规则集',
'settings.highlight.profileDeleteTitle': '删除规则集',
'settings.highlight.actions': '操作',
+5
View File
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '匯入方式',
'settings.highlight.importReplace': '取代現有規則',
'settings.highlight.importAppend': '附加到現有規則',
'settings.highlight.importReplaceTitle': '取代現有規則?',
'settings.highlight.importReplaceDesc': '匯入後目前全部規則將被這份檔案覆蓋,無法復原。',
'settings.highlight.importOk': '匯入',
'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '僅基礎',
'settings.highlight.modeOff': '關閉',
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
'settings.highlight.basic': '基礎規則',
'settings.highlight.basicHint': '開啟後,終端機高亮為「僅基礎」模式時該規則仍會生效',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '依分類分組',
'settings.highlight.category.safety': '安全',
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
'settings.highlight.profileRules': '包含的規則',
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
'settings.highlight.profileEmpty': '尚未建立規則集',
'settings.highlight.profileDeleteTitle': '刪除規則集',
'settings.highlight.actions': '操作',