From b7c7c5cd760dc269fe6ed1fdb87c493561606b31 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 28 Sep 2026 13:06:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(highlight):=20settings=20UI=20fixes=20from?= =?UTF-8?q?=20review=20=E2=80=94=20grouped=20view,=20import=20guard,=20bas?= =?UTF-8?q?ic=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- .../src/settings/HighlightImportExport.tsx | 39 +++++++++++---- .../src/settings/HighlightProfiles.tsx | 37 ++++++++++++-- src/renderer/src/settings/HighlightTab.tsx | 49 ++++++++++++++----- src/renderer/src/terminal/TerminalView.tsx | 6 ++- src/shared/i18n/dicts/en/settings.ts | 6 +++ src/shared/i18n/dicts/ja/settings.ts | 6 +++ src/shared/i18n/dicts/zh-CN/settings.ts | 5 ++ src/shared/i18n/dicts/zh-TW/settings.ts | 5 ++ 9 files changed, 127 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a82e7db..d375be9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.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.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` 带 `(? => { @@ -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({ {t('settings.highlight.importAppend')} {t('settings.highlight.importReplace')} - + {mode === 'replace' ? ( + // replace wipes the whole set, so it gets the same guard as deletes + void handleImport()} + > + + + ) : ( + + )} {result && ( - 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 = [ { 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) + ) + } > @@ -742,6 +763,12 @@ function HighlightEditor({ /> +
+ {t('settings.highlight.basic')} + patch({ basic: c })} /> + {t('settings.highlight.basicHint')} +
+
{t('settings.highlight.enabled')} patch({ enabled: c })} /> diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 2e45f9b..9aac79e 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -386,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent 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 => { diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index aebf8fb..21bf5f3 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -200,6 +200,9 @@ const settings: Record = { '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 = { '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 = { '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', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 22568d1..fa6d0e7 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -196,6 +196,9 @@ const settings: Record = { '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 = { '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 = { 'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)', 'settings.highlight.profileRules': '含めるルール', 'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません', + 'settings.highlight.profileExcluded': '{n} 件を除外:{list}', 'settings.highlight.profileEmpty': 'ルールセットがまだありません', 'settings.highlight.profileDeleteTitle': 'ルールセットを削除', 'settings.highlight.actions': '操作', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 08b1044..167eec8 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -190,6 +190,8 @@ const settings: Record = { '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 = { '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 = { 'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)', 'settings.highlight.profileRules': '包含的规则', 'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效', + 'settings.highlight.profileExcluded': '未包含 {n} 条:{list}', 'settings.highlight.profileEmpty': '尚未创建规则集', 'settings.highlight.profileDeleteTitle': '删除规则集', 'settings.highlight.actions': '操作', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index bc93d3a..d92bd5a 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -190,6 +190,8 @@ const settings: Record = { '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 = { '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 = { 'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)', 'settings.highlight.profileRules': '包含的規則', 'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效', + 'settings.highlight.profileExcluded': '未包含 {n} 條:{list}', 'settings.highlight.profileEmpty': '尚未建立規則集', 'settings.highlight.profileDeleteTitle': '刪除規則集', 'settings.highlight.actions': '操作',