Files
clash-verge-rev/src/components/profile/groups-editor-viewer.tsx
T

1144 lines
38 KiB
TypeScript

import {
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import MonacoEditor from "@monaco-editor/react";
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 yaml from "js-yaml";
import {
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { BaseSearchBox, 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 getSystem from "@/utils/get-system";
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 },
);
};
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 [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 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 sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const reorder = (
list: IProxyGroupConfig[],
startIndex: number,
endIndex: number,
) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
}
if (item.name === over.id) {
overIndex = index;
}
});
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
}
};
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
appendSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
}
if (item.name === over.id) {
overIndex = index;
}
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
}
};
const fetchContent = useCallback(async () => {
const data = await readProfileFile(property);
const obj = yaml.load(data) as ISeqProfileConfig | null;
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;
});
setPrevData(data);
setCurrData(data);
}, [property]);
useEffect(() => {
if (currData === "" || visualization !== true) {
return;
}
const obj = yaml.load(currData) as ISeqProfileConfig | null;
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;
});
});
}, [currData, visualization]);
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
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 = yaml.load(data) as {
"proxy-groups": IProxyGroupConfig[];
} | null;
const originProxiesObj = yaml.load(data) as { proxies: [] } | null;
const originProxies = originProxiesObj?.proxies || [];
const moreProxiesObj = yaml.load(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 = yaml.load(data) as {
"proxy-groups": IProxyGroupConfig[];
} | null;
const originProviderObj = yaml.load(data) as {
"proxy-providers": Record<string, unknown>;
} | null;
const originProvider = originProviderObj?.["proxy-providers"] || {};
const moreProviderObj = yaml.load(mergeData) as {
"proxy-providers": Record<string, unknown>;
} | null;
const moreProvider = moreProviderObj?.["proxy-providers"] || {};
const globalProviderObj = yaml.load(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]);
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);
}
await saveProfileFile(property, nextData);
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 display="flex" justifyContent="space-between">
{t("profiles.modals.groupsEditor.title")}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
}}
>
{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) => (
<li {...props} 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) => (
<li {...props} 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="https://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));
}}
/>
</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));
}}
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));
}}
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));
}}
/>
</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));
}}
/>
</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",
"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)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0;
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name;
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift;
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? "delete"
: "original"
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredGroupList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
]);
}
}}
/>
);
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name;
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
}
}}
/>
</List>
</>
) : (
<MonacoEditor
height="100%"
language="yaml"
value={currData}
theme={themeMode === "light" ? "light" : "vs-dark"}
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: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
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",
}));