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
@@ -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>
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '操作',
|
||||
|
||||
@@ -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': '操作',
|
||||
|
||||
@@ -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': '操作',
|
||||
|
||||
Reference in new issue
Block a user