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 => {