Files
clash-verge-rev/src/components/profile/groups-editor-viewer.tsx
T
oomeow 20141e791e fix(profile): virtualize editor lists for rule/proxy/group editors (#7944)
* fix(profile): virtualize editor lists for rule/proxy/group editors

* refactor(list): remove feedbackClone from sortable item and list integration

* refactor(profile): use type instead of export type for category

* refactor(ui): rename measureElementRef to measureElement in sortable components
2026-09-16 12:23:12 +00:00

1122 lines
36 KiB
TypeScript

import { arrayMove } from '@dnd-kit/helpers'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
} from '@mui/icons-material'
import {
Autocomplete,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
List,
ListItem,
ListItemText,
TextField,
styled,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import {
cancelIdleCallback,
requestIdleCallback,
} from 'foxact/request-idle-callback'
import * as yaml from 'js-yaml'
import {
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
readProfileFile,
saveProfileFile,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
proxiesUid: string
mergeUid: string
profileUid: string
property: string
open: boolean
onClose: () => void
onSave?: (prev?: string, curr?: string) => void
}
const builtinProxyPolicies = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS']
const PROXY_STRATEGY_LABEL_KEYS: Record<string, TranslationKey> = {
select: 'proxies.components.enums.strategies.select',
'url-test': 'proxies.components.enums.strategies.url-test',
fallback: 'proxies.components.enums.strategies.fallback',
'load-balance': 'proxies.components.enums.strategies.load-balance',
relay: 'proxies.components.enums.strategies.relay',
}
const PROXY_POLICY_LABEL_KEYS: Record<string, TranslationKey> =
builtinProxyPolicies.reduce(
(acc, policy) => {
acc[policy] =
`proxies.components.enums.policies.${policy}` as TranslationKey
return acc
},
{} as Record<string, TranslationKey>,
)
const normalizeDeleteSeq = (input?: unknown): string[] => {
if (!Array.isArray(input)) {
return []
}
const names = input
.map((item) => {
if (typeof item === 'string') {
return item
}
if (
item &&
typeof item === 'object' &&
'name' in item &&
typeof (item as { name: unknown }).name === 'string'
) {
return (item as { name: string }).name
}
return undefined
})
.filter(
(name): name is string => typeof name === 'string' && name.length > 0,
)
return Array.from(new Set(names))
}
const buildGroupsYaml = (
prepend: IProxyGroupConfig[],
append: IProxyGroupConfig[],
deleteList: string[],
) => {
return yaml.dump(
{
prepend,
append,
delete: deleteList,
},
{ forceQuotes: true },
)
}
const findRealIndex = (
list: IProxyGroupConfig[],
filtered: IProxyGroupConfig[],
filteredIndex: number,
): number => {
const item = filtered[filteredIndex]
if (!item) return -1
return list.findIndex((group) => group.name === item.name)
}
export const GroupsEditorViewer = (props: Props) => {
const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } =
props
const { t } = useTranslation()
const translateStrategy = useCallback(
(value: string) =>
PROXY_STRATEGY_LABEL_KEYS[value]
? t(PROXY_STRATEGY_LABEL_KEYS[value])
: value,
[t],
)
const translatePolicy = useCallback(
(value: string) =>
PROXY_POLICY_LABEL_KEYS[value]
? t(PROXY_POLICY_LABEL_KEYS[value])
: value,
[t],
)
const themeMode = useThemeMode()
const editorRef = useRef<MonacoEditorInstance | null>(null)
const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true)
const [match, setMatch] = useState(() => (_: string) => true)
const [interfaceNameList, setInterfaceNameList] = useState<string[]>([])
const { control, ...formIns } = useForm<IProxyGroupConfig>({
defaultValues: {
type: 'select',
name: '',
interval: 300,
timeout: 5000,
'max-failed-times': 5,
lazy: true,
},
})
const [groupList, setGroupList] = useState<IProxyGroupConfig[]>([])
const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([])
const [proxyProviderList, setProxyProviderList] = useState<string[]>([])
const [prependSeq, setPrependSeq] = useState<IProxyGroupConfig[]>([])
const [appendSeq, setAppendSeq] = useState<IProxyGroupConfig[]>([])
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const hasLoadedSeqConfigRef = useRef(false)
const filteredPrependSeq = useMemo(
() => prependSeq.filter((group) => match(group.name)),
[prependSeq, match],
)
const filteredGroupList = useMemo(
() => groupList.filter((group) => match(group.name)),
[groupList, match],
)
const filteredAppendSeq = useMemo(
() => appendSeq.filter((group) => match(group.name)),
[appendSeq, match],
)
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredGroupList,
filteredAppendSeq,
(group) => group.name,
),
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<GroupItem
type={isDeleted ? 'delete' : 'original'}
group={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
}}
/>
)
}
if (category === 'prepend') {
return (
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined
setPrevData(data)
setCurrData(data)
if (obj === undefined) {
setVisualization(false)
return
}
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
setDeleteSeq((prev) => {
const normalized = normalizeDeleteSeq(obj?.delete)
if (
normalized.length === prev.length &&
normalized.every((item, index) => item === prev[index])
) {
return prev
}
return normalized
})
hasLoadedSeqConfigRef.current = true
}, [property])
const handleVisualizationToggle = () => {
if (visualization) {
setVisualization(false)
return
}
const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined
if (obj === undefined) {
hasLoadedSeqConfigRef.current = false
return
}
hasLoadedSeqConfigRef.current = true
startTransition(() => {
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
setDeleteSeq((prev) => {
const normalized = normalizeDeleteSeq(obj?.delete)
if (
normalized.length === prev.length &&
normalized.every((item, index) => item === prev[index])
) {
return prev
}
return normalized
})
})
setVisualization(true)
}
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
if (hasLoadedSeqConfigRef.current && prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
if (!hasLoadedSeqConfigRef.current) {
return
}
try {
setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq))
} catch (e) {
console.warn('[GroupsEditorViewer] yaml.dump failed:', e)
// 防止异常导致UI卡死
}
}
const handle = requestIdleCallback(serialize)
return () => {
cancelIdleCallback(handle)
}
}
}, [prependSeq, appendSeq, deleteSeq])
const fetchProxyPolicy = useCallback(async () => {
const data = await readProfileFile(profileUid)
const proxiesData = await readProfileFile(proxiesUid)
const originGroupsObj = parseYamlSafe(data) as {
'proxy-groups': IProxyGroupConfig[]
} | null
const originProxiesObj = parseYamlSafe(data) as { proxies: [] } | null
const originProxies = originProxiesObj?.proxies || []
const moreProxiesObj = parseYamlSafe(
proxiesData,
) as ISeqProfileConfig | null
const morePrependProxies = moreProxiesObj?.prepend || []
const moreAppendProxies = moreProxiesObj?.append || []
const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete)
const proxies = morePrependProxies.concat(
originProxies.filter((proxy: any) => {
const proxyName =
typeof proxy === 'string'
? proxy
: (proxy?.name as string | undefined)
return proxyName ? !moreDeleteProxies.includes(proxyName) : true
}),
moreAppendProxies,
)
const proxyNames = proxies
.map((proxy: any) =>
typeof proxy === 'string' ? proxy : (proxy?.name as string | undefined),
)
.filter(
(name): name is string => typeof name === 'string' && name.length > 0,
)
const computedPolicyList = builtinProxyPolicies.concat(
prependSeq.map((group: IProxyGroupConfig) => group.name),
(originGroupsObj?.['proxy-groups'] || [])
.map((group: IProxyGroupConfig) => group.name)
.filter((name) => !deleteSeq.includes(name)),
appendSeq.map((group: IProxyGroupConfig) => group.name),
proxyNames,
)
setProxyPolicyList(Array.from(new Set(computedPolicyList)))
}, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid])
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid)
const mergeData = await readProfileFile(mergeUid)
const globalMergeData = await readProfileFile('Merge')
const originGroupsObj = parseYamlSafe(data) as {
'proxy-groups': IProxyGroupConfig[]
} | null
const originProviderObj = parseYamlSafe(data) as {
'proxy-providers': Record<string, unknown>
} | null
const originProvider = originProviderObj?.['proxy-providers'] || {}
const moreProviderObj = parseYamlSafe(mergeData) as {
'proxy-providers': Record<string, unknown>
} | null
const moreProvider = moreProviderObj?.['proxy-providers'] || {}
const globalProviderObj = parseYamlSafe(globalMergeData) as {
'proxy-providers': Record<string, unknown>
} | null
const globalProvider = globalProviderObj?.['proxy-providers'] || {}
const provider = Object.assign(
{},
originProvider,
moreProvider,
globalProvider,
)
setProxyProviderList(Object.keys(provider))
setGroupList(originGroupsObj?.['proxy-groups'] || [])
}, [mergeUid, profileUid])
const getInterfaceNameList = useCallback(async () => {
const list = await getNetworkInterfaces()
setInterfaceNameList(list)
}, [])
useEffect(() => {
if (!open) return
fetchProxyPolicy()
}, [fetchProxyPolicy, open])
useEffect(() => {
if (!open) return
fetchContent()
fetchProfile()
getInterfaceNameList()
}, [fetchContent, fetchProfile, getInterfaceNameList, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const validateGroup = () => {
const group = formIns.getValues()
if (group.name === '') {
throw new Error(t('profiles.modals.groupsEditor.errors.nameRequired'))
}
}
const handleSave = useLockFn(async () => {
try {
const nextData = visualization
? buildGroupsYaml(prependSeq, appendSeq, deleteSeq)
: currData
if (visualization) {
setCurrData(nextData)
}
if (!(await saveProfileFile(property, nextData))) {
await fetchContent()
onClose()
return
}
showNotice.success('shared.feedback.notifications.saved')
setPrevData(nextData)
onSave?.(prevData, nextData)
onClose()
} catch (err) {
showNotice.error(err)
}
})
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xl"
fullWidth
disableEnforceFocus={!visualization}
>
<DialogTitle>
{
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
{t('profiles.modals.groupsEditor.title')}
<Box>
<Button
variant="contained"
size="small"
onClick={handleVisualizationToggle}
>
{visualization
? t('shared.editorModes.advanced')
: t('shared.editorModes.visualization')}
</Button>
</Box>
</Box>
}
</DialogTitle>
<DialogContent
sx={{ display: 'flex', width: 'auto', height: 'calc(100vh - 185px)' }}
>
{visualization ? (
<>
<List
sx={{
width: '50%',
padding: '0 10px',
}}
>
<Box
sx={{
height: 'calc(100% - 80px)',
overflowY: 'auto',
}}
>
<Controller
name="type"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t('profiles.modals.groupsEditor.fields.type')}
/>
<Autocomplete
size="small"
sx={{ width: 'calc(100% - 150px)' }}
options={[
'select',
'url-test',
'fallback',
'load-balance',
'relay',
]}
value={field.value}
getOptionLabel={translateStrategy}
renderOption={(props, option) => {
const { key, ...optionProps } = props
return (
<li
key={key}
{...optionProps}
title={translateStrategy(option)}
>
{translateStrategy(option)}
</li>
)
}}
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
</Item>
)}
/>
<Controller
name="name"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t('profiles.modals.groupsEditor.fields.name')}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}
error={field.value === ''}
required={true}
/>
</Item>
)}
/>
<Controller
name="icon"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t('profiles.modals.groupsEditor.fields.icon')}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}
/>
</Item>
)}
/>
<Controller
name="proxies"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.proxies',
)}
/>
<Autocomplete
size="small"
sx={{
width: 'calc(100% - 150px)',
}}
multiple
options={proxyPolicyList}
disableCloseOnSelect
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
renderOption={(props, option) => {
const { key, ...optionProps } = props
return (
<li
key={key}
{...optionProps}
title={translatePolicy(option)}
>
{translatePolicy(option)}
</li>
)
}}
getOptionLabel={translatePolicy}
/>
</Item>
)}
/>
<Controller
name="use"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.provider',
)}
/>
<Autocomplete
size="small"
sx={{ width: 'calc(100% - 150px)' }}
multiple
options={proxyProviderList}
disableCloseOnSelect
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
</Item>
)}
/>
<Controller
name="url"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.healthCheckUrl',
)}
/>
<TextField
autoComplete="new-password"
placeholder="http://cp.cloudflare.com/generate_204"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}
/>
</Item>
)}
/>
<Controller
name="expected-status"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.expectedStatus',
)}
/>
<TextField
autoComplete="new-password"
placeholder="*"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
)}
/>
<Controller
name="interval"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.interval',
)}
/>
<TextField
autoComplete="new-password"
placeholder="300"
type="number"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10))
}}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t('shared.units.seconds')}
</InputAdornment>
),
},
}}
/>
</Item>
)}
/>
<Controller
name="timeout"
control={control}
render={({ field }) => (
<Item>
<ListItemText primary={t('shared.labels.timeout')} />
<TextField
autoComplete="new-password"
placeholder="5000"
type="number"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10))
}}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t('shared.units.milliseconds')}
</InputAdornment>
),
},
}}
/>
</Item>
)}
/>
<Controller
name="max-failed-times"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.maxFailedTimes',
)}
/>
<TextField
autoComplete="new-password"
placeholder="5"
type="number"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
)}
/>
<Controller
name="interface-name"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.interfaceName',
)}
/>
<Autocomplete
size="small"
sx={{ width: 'calc(100% - 150px)' }}
options={interfaceNameList}
value={field.value}
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
</Item>
)}
/>
<Controller
name="routing-mark"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.routingMark',
)}
/>
<TextField
autoComplete="new-password"
type="number"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
)}
/>
<Controller
name="filter"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.filter',
)}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}
/>
</Item>
)}
/>
<Controller
name="exclude-filter"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.excludeFilter',
)}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}
/>
</Item>
)}
/>
<Controller
name="exclude-type"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.excludeType',
)}
/>
<Autocomplete
multiple
options={[
'Direct',
'Reject',
'RejectDrop',
'Compatible',
'Pass',
'Dns',
'Shadowsocks',
'ShadowsocksR',
'Snell',
'Socks5',
'Http',
'Vmess',
'Vless',
'Trojan',
'Hysteria',
'Hysteria2',
'WireGuard',
'Tuic',
'Mieru',
'Masque',
'AnyTLS',
'Sudoku',
'Relay',
'Selector',
'Fallback',
'URLTest',
'LoadBalance',
'Ssh',
]}
size="small"
disableCloseOnSelect
sx={{ width: 'calc(100% - 150px)' }}
value={field.value?.split('|')}
onChange={(_, value) => {
field.onChange(value.join('|'))
}}
renderInput={(params) => <TextField {...params} />}
/>
</Item>
)}
/>
<Controller
name="include-all"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.includeAll',
)}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
<Controller
name="include-all-proxies"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.includeAllProxies',
)}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
<Controller
name="include-all-providers"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.fields.includeAllProviders',
)}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
<Controller
name="lazy"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t('profiles.modals.groupsEditor.toggles.lazy')}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
<Controller
name="disable-udp"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.toggles.disableUdp',
)}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
<Controller
name="hidden"
control={control}
render={({ field }) => (
<Item>
<ListItemText
primary={t(
'profiles.modals.groupsEditor.toggles.hidden',
)}
/>
<Switch checked={field.value} {...field} />
</Item>
)}
/>
</Box>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
try {
validateGroup()
for (const item of [...prependSeq, ...groupList]) {
if (item.name === formIns.getValues().name) {
throw new Error(
t('profiles.modals.groupsEditor.errors.nameExists'),
)
}
}
setPrependSeq([formIns.getValues(), ...prependSeq])
} catch (err) {
showNotice.error(err)
}
}}
>
{t('profiles.modals.groupsEditor.actions.prepend')}
</Button>
</Item>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
try {
validateGroup()
for (const item of [...appendSeq, ...groupList]) {
if (item.name === formIns.getValues().name) {
throw new Error(
t('profiles.modals.groupsEditor.errors.nameExists'),
)
}
}
setAppendSeq([...appendSeq, formIns.getValues()])
} catch (err) {
showNotice.error(err)
}
}}
>
{t('profiles.modals.groupsEditor.actions.append')}
</Button>
</Item>
</List>
<List
sx={{
width: '50%',
padding: '0 10px',
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
<MonacoEditor
height="100%"
language="yaml"
value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{
tabSize: 2, // 根据语言类型设置缩进大小
minimap: {
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
},
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
},
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: MONACO_FONT_FAMILY,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value ?? '')}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t('shared.actions.cancel')}
</Button>
<Button onClick={handleSave} variant="contained">
{t('shared.actions.save')}
</Button>
</DialogActions>
</Dialog>
)
}
const Item = styled(ListItem)(() => ({
padding: '5px 2px',
}))