diff --git a/src/components/profile/editor-viewer.tsx b/src/components/profile/editor-viewer.tsx index c1351d66..e4951735 100644 --- a/src/components/profile/editor-viewer.tsx +++ b/src/components/profile/editor-viewer.tsx @@ -9,176 +9,184 @@ import { DialogTitle, } from "@mui/material"; import { useThemeMode } from "@/services/states"; -import { readProfileFile, saveProfileFile } from "@/services/cmds"; import { Notice } from "@/components/base"; import { nanoid } from "nanoid"; import getSystem from "@/utils/get-system"; import * as monaco from "monaco-editor"; -import { editor } from "monaco-editor/esm/vs/editor/editor.api"; +import MonacoEditor from "react-monaco-editor"; import { configureMonacoYaml } from "monaco-yaml"; - import { type JSONSchema7 } from "json-schema"; import metaSchema from "meta-json-schema/schemas/meta-json-schema.json"; import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json"; import pac from "types-pac/pac.d.ts?raw"; -interface Props { - title?: string | ReactNode; - mode: "profile" | "text"; - property: string; - open: boolean; - readOnly?: boolean; - language: "yaml" | "javascript" | "css"; - schema?: "clash" | "merge"; - onClose: () => void; - onChange?: (prev?: string, curr?: string) => void; +type Language = "yaml" | "javascript" | "css"; +type Schema = LanguageSchemaMap[T]; +interface LanguageSchemaMap { + yaml: "clash" | "merge"; + javascript: never; + css: never; } -// yaml worker -configureMonacoYaml(monaco, { - validate: true, - enableSchemaRequest: true, - schemas: [ - { - uri: "http://example.com/meta-json-schema.json", - fileMatch: ["**/*.clash.yaml"], - //@ts-ignore - schema: metaSchema as JSONSchema7, - }, - { - uri: "http://example.com/clash-verge-merge-json-schema.json", - fileMatch: ["**/*.merge.yaml"], - //@ts-ignore - schema: mergeSchema as JSONSchema7, - }, - ], -}); -// PAC definition -monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); -monaco.languages.registerCompletionItemProvider("javascript", { - provideCompletionItems: (model, position) => ({ - suggestions: [ +interface Props { + open: boolean; + title?: string | ReactNode; + initialData: Promise; + readOnly?: boolean; + language: T; + schema?: Schema; + onChange?: (prev?: string, curr?: string) => void; + onSave?: (prev?: string, curr?: string) => void; + onClose: () => void; +} + +let initialized = false; +const monacoInitialization = () => { + if (initialized) return; + + // configure yaml worker + configureMonacoYaml(monaco, { + validate: true, + enableSchemaRequest: true, + schemas: [ { - label: "%mixed-port%", - kind: monaco.languages.CompletionItemKind.Text, - insertText: "%mixed-port%", - range: { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: model.getWordUntilPosition(position).startColumn - 1, - endColumn: model.getWordUntilPosition(position).endColumn - 1, - }, + uri: "http://example.com/meta-json-schema.json", + fileMatch: ["**/*.clash.yaml"], + // @ts-ignore + schema: metaSchema as JSONSchema7, + }, + { + uri: "http://example.com/clash-verge-merge-json-schema.json", + fileMatch: ["**/*.merge.yaml"], + // @ts-ignore + schema: mergeSchema as JSONSchema7, }, ], - }), -}); + }); + // configure PAC definition + monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); -export const EditorViewer = (props: Props) => { - const { - title, - mode, - property, - open, - readOnly, - language, - schema, - onClose, - onChange, - } = props; + initialized = true; +}; + +export const EditorViewer = (props: Props) => { const { t } = useTranslation(); - const editorRef = useRef(); - const instanceRef = useRef(null); const themeMode = useThemeMode(); - const prevData = useRef(); - useEffect(() => { - if (!open) return; + const { + open = false, + title = t("Edit File"), + initialData = Promise.resolve(""), + readOnly = false, + language = "yaml", + schema, + onChange, + onSave, + onClose, + } = props; - let fetchContent; - switch (mode) { - case "profile": // profile文件 - fetchContent = readProfileFile(property); - break; - case "text": // 文本内容 - fetchContent = Promise.resolve(property); - break; - } - fetchContent.then((data) => { - const dom = editorRef.current; + const editorRef = useRef(); + const prevData = useRef(""); + const currData = useRef(""); - if (!dom) return; + const editorWillMount = () => { + monacoInitialization(); // initialize monaco + }; - if (instanceRef.current) instanceRef.current.dispose(); + const editorDidMount = async ( + editor: monaco.editor.IStandaloneCodeEditor + ) => { + editorRef.current = editor; + // retrieve initial data + await initialData.then((data) => { + prevData.current = data; + currData.current = data; + + // create and set model const uri = monaco.Uri.parse(`${nanoid()}.${schema}.${language}`); const model = monaco.editor.createModel(data, language, uri); - instanceRef.current = editor.create(editorRef.current, { - model: model, - language: language, - tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小 - theme: themeMode === "light" ? "vs" : "vs-dark", - minimap: { enabled: dom.clientWidth >= 1000 }, // 超过一定宽度显示minimap滚动条 - mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例 - readOnly: readOnly, // 只读模式 - readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息 - renderValidationDecorations: "on", // 只读模式下显示校验信息 - 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: true, // 连字符 - smoothScrolling: true, // 平滑滚动 - }); - - prevData.current = data; + editorRef.current?.setModel(model); }); + }; - return () => { - if (instanceRef.current) { - instanceRef.current.dispose(); - instanceRef.current = null; - } - }; - }, [open]); - - const onSave = useLockFn(async () => { - const currData = instanceRef.current?.getValue(); - - if (currData == null) return; - + const handleChange = useLockFn(async (value: string | undefined) => { try { - if (mode === "profile") { - await saveProfileFile(property, currData); - } - onChange?.(prevData.current, currData); + currData.current = value; + onChange?.(prevData.current, currData.current); + } catch (err: any) { + Notice.error(err.message || err.toString()); + } + }); + + const handleSave = useLockFn(async () => { + try { + !readOnly && onSave?.(prevData.current, currData.current); onClose(); } catch (err: any) { Notice.error(err.message || err.toString()); } }); + const handleClose = useLockFn(async () => { + try { + onClose(); + } catch (err: any) { + Notice.error(err.message || err.toString()); + } + }); + + useEffect(() => { + return () => { + editorRef.current?.dispose(); + editorRef.current = undefined; + }; + }, []); + return ( - {title ?? t("Edit File")} + {title} -
+ = 1500, // 超过一定宽度显示minimap滚动条 + }, + mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例 + readOnly: readOnly, // 只读模式 + readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息 + renderValidationDecorations: "on", // 只读模式下显示校验信息 + 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: true, // 连字符 + smoothScrolling: true, // 平滑滚动 + }} + editorWillMount={editorWillMount} + editorDidMount={editorDidMount} + onChange={handleChange} + /> - {!readOnly && ( - )} diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 1dbb44df..9c803051 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -17,7 +17,12 @@ import { } from "@mui/material"; import { RefreshRounded, DragIndicator } from "@mui/icons-material"; import { useLoadingCache, useSetLoadingCache } from "@/services/states"; -import { updateProfile, viewProfile } from "@/services/cmds"; +import { + viewProfile, + readProfileFile, + updateProfile, + saveProfileFile, +} from "@/services/cmds"; import { Notice } from "@/components/base"; import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer"; @@ -37,20 +42,13 @@ interface Props { itemData: IProfileItem; onSelect: (force: boolean) => void; onEdit: () => void; - onChange?: (prev?: string, curr?: string) => void; + onSave?: (prev?: string, curr?: string) => void; onDelete: () => void; } export const ProfileItem = (props: Props) => { - const { - selected, - activating, - itemData, - onSelect, - onEdit, - onChange, - onDelete, - } = props; + const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } = + props; const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props.id }); @@ -474,52 +472,62 @@ export const ProfileItem = (props: Props) => { { + await saveProfileFile(uid, curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setFileOpen(false)} /> setRulesOpen(false)} /> { + await saveProfileFile(option?.proxies ?? "", curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setProxiesOpen(false)} /> { + await saveProfileFile(option?.proxies ?? "", curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setGroupsOpen(false)} /> { + await saveProfileFile(option?.merge ?? "", curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setMergeOpen(false)} /> { + await saveProfileFile(option?.script ?? "", curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setScriptOpen(false)} /> void; + onSave?: (prev?: string, curr?: string) => void; } // profile enhanced item export const ProfileMore = (props: Props) => { - const { id, logInfo = [], onChange } = props; + const { id, logInfo = [], onSave } = props; - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); const [fileOpen, setFileOpen] = useState(false); @@ -169,12 +169,15 @@ export const ProfileMore = (props: Props) => { { + await saveProfileFile(id, curr ?? ""); + onSave && onSave(prev, curr); + }} onClose={() => setFileOpen(false)} /> diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 82917b80..99fa2d2b 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -42,7 +42,7 @@ interface Props { property: string; open: boolean; onClose: () => void; - onChange?: (prev?: string, curr?: string) => void; + onSave?: (prev?: string, curr?: string) => void; } const portValidator = (value: string): boolean => { @@ -227,7 +227,7 @@ const rules: { const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; export const RulesEditorViewer = (props: Props) => { - const { title, profileUid, property, open, onClose, onChange } = props; + const { title, profileUid, property, open, onClose, onSave } = props; const { t } = useTranslation(); const [prevData, setPrevData] = useState(""); @@ -330,7 +330,7 @@ export const RulesEditorViewer = (props: Props) => { },${proxyPolicy}${ruleType.noResolve && noResolve ? ",no-resolve" : ""}`; }; - const onSave = useLockFn(async () => { + const handleSave = useLockFn(async () => { try { let currData = yaml.dump({ prepend: prependSeq, @@ -338,7 +338,7 @@ export const RulesEditorViewer = (props: Props) => { delete: deleteSeq, }); await saveProfileFile(property, currData); - onChange?.(prevData, currData); + onSave?.(prevData, currData); onClose(); } catch (err: any) { Notice.error(err.message || err.toString()); @@ -575,7 +575,7 @@ export const RulesEditorViewer = (props: Props) => { {t("Cancel")} - diff --git a/src/components/setting/mods/config-viewer.tsx b/src/components/setting/mods/config-viewer.tsx index 3f04356d..76d8c48a 100644 --- a/src/components/setting/mods/config-viewer.tsx +++ b/src/components/setting/mods/config-viewer.tsx @@ -22,15 +22,14 @@ export const ConfigViewer = forwardRef((_, ref) => { return ( {t("Runtime Config")} } - mode="text" - property={runtimeConfig} - open={open} + initialData={Promise.resolve(runtimeConfig)} readOnly language="yaml" schema="clash" diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 96374c03..e8aa3a31 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -262,21 +262,18 @@ export const SysproxyViewer = forwardRef((props, ref) => { {t("Edit")} PAC { + onSave={(_prev, curr) => { let pac = DEFAULT_PAC; if (curr && curr.trim().length > 0) { pac = curr; } setValue((v) => ({ ...v, pac_content: pac })); }} - onClose={() => { - setEditorOpen(false); - }} + onClose={() => setEditorOpen(false)} /> diff --git a/src/components/setting/mods/theme-viewer.tsx b/src/components/setting/mods/theme-viewer.tsx index fc74b251..8d52d7fb 100644 --- a/src/components/setting/mods/theme-viewer.tsx +++ b/src/components/setting/mods/theme-viewer.tsx @@ -124,12 +124,11 @@ export const ThemeViewer = forwardRef((props, ref) => { {t("Edit")} CSS { + onSave={(_prev, curr) => { theme.css_injection = curr; handleChange("css_injection"); }} diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index dedfbee9..a25af166 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -50,7 +50,6 @@ import { BaseStyledTextField } from "@/components/base/base-styled-text-field"; import { listen } from "@tauri-apps/api/event"; import { readTextFile } from "@tauri-apps/api/fs"; import { readText } from "@tauri-apps/api/clipboard"; -import { EditorViewer } from "@/components/profile/editor-viewer"; const ProfilePage = () => { const { t } = useTranslation(); @@ -378,7 +377,7 @@ const ProfilePage = () => { itemData={item} onSelect={(f) => onSelect(item.uid, f)} onEdit={() => viewerRef.current?.edit(item)} - onChange={async (prev, curr) => { + onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { await onEnhance(); } @@ -401,7 +400,7 @@ const ProfilePage = () => { { + onSave={async (prev, curr) => { if (prev !== curr) { await onEnhance(); } @@ -412,7 +411,7 @@ const ProfilePage = () => { { + onSave={async (prev, curr) => { if (prev !== curr) { await onEnhance(); }