From 837508c02c3eec7cc4957b9438c3f7ae49cad51d Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Sun, 15 Mar 2026 14:04:59 +0800 Subject: [PATCH] feat(monaco): reintroduce meta-json-schema (#6509) * refactor(editor): make EditorViewer controlled and unify document state handling * fix(monaco-yaml): add patchCreateWebWorker * feat(monaco): reintroduce meta-json-schema * fix(editor): reset document state on target change --- Changelog.md | 1 + package.json | 2 + pnpm-lock.yaml | 9 + src/components/profile/editor-viewer.tsx | 519 +++++------------- src/components/profile/profile-item.tsx | 85 ++- src/components/profile/profile-more.tsx | 34 +- src/components/setting/mods/config-viewer.tsx | 23 +- .../setting/mods/sysproxy-viewer.tsx | 35 +- src/components/setting/mods/theme-viewer.tsx | 39 +- src/hooks/use-editor-document.ts | 61 ++ src/main.tsx | 2 +- src/services/monaco.ts | 70 +++ src/utils/monaco.ts | 30 - src/utils/yaml.worker.ts | 2 + 14 files changed, 431 insertions(+), 481 deletions(-) create mode 100644 src/hooks/use-editor-document.ts create mode 100644 src/services/monaco.ts delete mode 100644 src/utils/monaco.ts create mode 100644 src/utils/yaml.worker.ts diff --git a/Changelog.md b/Changelog.md index 277b1ccac..9efaae6ce 100644 --- a/Changelog.md +++ b/Changelog.md @@ -25,5 +25,6 @@ - Linux 默认使用内置窗口控件 - 实现排除自定义网段的校验 - 移除冗余的自动备份触发条件 +- 恢复内置编辑器对 mihomo 配置的语法提示 diff --git a/package.json b/package.json index 0b0b43b72..c62081f89 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "i18next": "^25.8.14", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", + "meta-json-schema": "^1.19.21", "monaco-editor": "^0.55.1", "monaco-yaml": "^5.4.1", "nanoid": "^5.1.6", @@ -139,6 +140,7 @@ "core-js", "es5-ext", "esbuild", + "meta-json-schema", "unrs-resolver" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afe1c9cd9..770b6dd12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: lodash-es: specifier: ^4.17.23 version: 4.17.23 + meta-json-schema: + specifier: ^1.19.21 + version: 1.19.21 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -3136,6 +3139,10 @@ packages: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} + meta-json-schema@1.19.21: + resolution: {integrity: sha512-PkEdW1H+C0HNt+Bw5qAfBHkXgN0ZXB1g5YBhzCRzUNdLnWWe59lMgXRri85IizRRRVe8bVLffDMNbPb+4wrU3Q==} + engines: {node: '>=18', pnpm: '>=9'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -6940,6 +6947,8 @@ snapshots: meow@13.2.0: {} + meta-json-schema@1.19.21: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 diff --git a/src/components/profile/editor-viewer.tsx b/src/components/profile/editor-viewer.tsx index cab035ffa..05c7eb6eb 100644 --- a/src/components/profile/editor-viewer.tsx +++ b/src/components/profile/editor-viewer.tsx @@ -16,374 +16,168 @@ import { } from "@mui/material"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { useLockFn } from "ahooks"; -import * as monaco from "monaco-editor"; -import { configureMonacoYaml } from "monaco-yaml"; -import { nanoid } from "nanoid"; -import { ReactNode, useEffect, useRef, useState } from "react"; +import type { editor } from "monaco-editor"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import pac from "types-pac/pac.d.ts?raw"; import { BaseLoadingOverlay } from "@/components/base"; +import { beforeEditorMount } from "@/services/monaco"; import { showNotice } from "@/services/notice-service"; import { useThemeMode } from "@/services/states"; import debounce from "@/utils/debounce"; import getSystem from "@/utils/get-system"; + const appWindow = getCurrentWebviewWindow(); -type Language = "yaml" | "javascript" | "css"; +export type EditorLanguage = "yaml" | "javascript" | "css"; -interface Props { +export interface EditorViewerProps { open: boolean; title?: string | ReactNode; - // Initial content loader: prefer passing a stable function. A plain Promise is supported, - // but it won't trigger background refreshes and should be paired with a stable `dataKey`. - initialData: Promise | (() => Promise); - // Logical document id; reloads when this or language changes. - dataKey?: string | number; + value: string; + language: EditorLanguage; + path: string; readOnly?: boolean; - language: T; - onChange?: (prev?: string, curr?: string) => void; - onSave?: (prev?: string, curr?: string) => void | Promise; + loading?: boolean; + dirty?: boolean; + saveDisabled?: boolean; + onChange?: (value: string) => void; + onSave?: () => void | Promise; onClose: () => void; + onValidate?: (markers: editor.IMarker[]) => void; } -let initialized = false; -const monacoInitialization = () => { - if (initialized) return; - - // YAML worker setup - configureMonacoYaml(monaco, { - validate: true, - enableSchemaRequest: true, - }); - // PAC type definitions for JS suggestions - monaco.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); - - initialized = true; -}; - -export const EditorViewer = (props: Props) => { +export const EditorViewer = ({ + open, + title, + value, + language, + path, + readOnly = false, + loading = false, + dirty, + saveDisabled = false, + onChange, + onSave, + onClose, + onValidate, +}: EditorViewerProps) => { const { t } = useTranslation(); const themeMode = useThemeMode(); const [isMaximized, setIsMaximized] = useState(false); - - const { - open = false, - title, - initialData, - dataKey, - readOnly = false, - language = "yaml", - onChange, - onSave, - onClose, - } = props; + const editorRef = useRef(null); const resolvedTitle = title ?? t("profiles.components.menu.editFile"); + const disableSave = loading || saveDisabled || dirty === false; - const editorRef = useRef(undefined); - const prevDataRef = useRef(""); - const currDataRef = useRef(""); - // Hold the latest loader without making effects depend on its identity - const initialDataRef = useRef["initialData"]>(initialData); - // Track mount/open state to prevent setState after unmount/close - const isMountedRef = useRef(true); - const openRef = useRef(open); - useEffect(() => { - openRef.current = open; - }, [open]); - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; + const syncMaximizedState = useCallback(async () => { + try { + setIsMaximized(await appWindow.isMaximized()); + } catch { + setIsMaximized(false); + } }, []); - const [initialText, setInitialText] = useState(null); - const [modelPath, setModelPath] = useState(""); - const modelChangeDisposableRef = useRef(null); - // Unique per-component instance id to avoid shared Monaco models across dialogs - const instanceIdRef = useRef(nanoid()); - // Disable actions while loading or before modelPath is ready - const isLoading = initialText === null || !modelPath; - // Track if background refresh failed; offer a retry action in UI - const [refreshFailed, setRefreshFailed] = useState(null); - // Skip the first background refresh triggered by [open, modelPath, dataKey] - // to avoid double-invoking the loader right after the initial load. - const skipNextRefreshRef = useRef(false); - // Monotonic token to cancel stale background refreshes - const reloadTokenRef = useRef(0); - // Track whether the editor has a usable baseline (either loaded or fallback). - // This avoids saving before the model/path are ready, while still allowing recovery - // when the initial load fails but an empty buffer is presented. - const [hasLoadedOnce, setHasLoadedOnce] = useState(false); - // Editor should only be read-only when explicitly requested by prop. - // A refresh/load failure must not lock the editor to allow manual recovery. - const effectiveReadOnly = readOnly; - // Keep ref in sync with prop without triggering loads - useEffect(() => { - initialDataRef.current = initialData; - }, [initialData]); - // Helper to (soft) reload latest source and apply only if the user hasn't typed yet - const reloadLatest = useLockFn(async () => { - // Snapshot the model/doc identity and bump a token so older calls can't win - const myToken = ++reloadTokenRef.current; - const expectedModelPath = modelPath; - const expectedKey = dataKey; - if (isMountedRef.current && openRef.current) { - // Clear previous error (UI hint) at the start of a new attempt - setRefreshFailed(null); - } - try { - const src = initialDataRef.current; - const promise = - typeof src === "function" - ? (src as () => Promise)() - : (src ?? Promise.resolve("")); - const next = await promise; - // Abort if component/dialog state changed meanwhile: - // - unmounted or closed - // - document switched (modelPath/dataKey no longer match) - // - a newer reload was started - if ( - !isMountedRef.current || - !openRef.current || - expectedModelPath !== modelPath || - expectedKey !== dataKey || - myToken !== reloadTokenRef.current - ) { - return; - } - // Only update when untouched and value changed - const userUntouched = currDataRef.current === prevDataRef.current; - if (userUntouched && next !== prevDataRef.current) { - prevDataRef.current = next; - currDataRef.current = next; - editorRef.current?.setValue(next); - } - // Ensure any previous error state is cleared after a successful refresh - if (isMountedRef.current && openRef.current) { - setRefreshFailed(null); - } - // If we previously failed to load, a successful refresh establishes a valid baseline - if (isMountedRef.current && openRef.current) { - setHasLoadedOnce(true); - } - } catch (err) { - // Only report if still mounted/open and this call is the latest - if ( - isMountedRef.current && - openRef.current && - myToken === reloadTokenRef.current - ) { - setRefreshFailed(err ?? true); - showNotice.error( - "shared.feedback.notifications.common.refreshFailed", - err, - ); - } - } - }); - // Background refresh: when the dialog/model is ready and the underlying resource key changes, - // try to refresh content (only if user hasn't typed). Do NOT depend on `initialData` function - // identity because callers often pass inline lambdas that change every render. - useEffect(() => { - if (!open) return; - // Only attempt after initial model is ready to avoid racing the initial load - if (!modelPath) return; - // Avoid immediate double-load on open: the initial load has just completed. - if (skipNextRefreshRef.current) { - skipNextRefreshRef.current = false; - return; - } - // Only meaningful when a callable loader is provided (plain Promise cannot be "recalled") - if (typeof initialDataRef.current === "function") { - void reloadLatest(); - } - }, [open, modelPath, dataKey, reloadLatest]); - - const beforeMount = () => { - monacoInitialization(); - }; - - // Prepare initial content and a stable model path for monaco-react - /* eslint-disable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ - useEffect(() => { - if (!open) return; - let cancelled = false; - // Clear state up-front to avoid showing stale content while loading - setInitialText(null); - setModelPath(""); - // Clear any stale refresh error when starting a new load - setRefreshFailed(null); - // Reset initial-load success flag on open/start - setHasLoadedOnce(false); - // We will perform an explicit initial load below; skip the first background refresh. - skipNextRefreshRef.current = true; - prevDataRef.current = undefined; - currDataRef.current = undefined; - - (async () => { - try { - const dataSource = initialDataRef.current; - const dataPromise = - typeof dataSource === "function" - ? (dataSource as () => Promise)() - : (dataSource ?? Promise.resolve("")); - const data = await dataPromise; - if (cancelled) return; - prevDataRef.current = data; - currDataRef.current = data; - - setInitialText(data); - // Build a stable model path and avoid "undefined" in the name - const pathParts = [String(dataKey ?? nanoid()), instanceIdRef.current]; - pathParts.push(language); - - setModelPath(pathParts.join(".")); - // Successful initial load should clear any previous refresh error flag - setRefreshFailed(null); - // Mark that we have a valid baseline content - setHasLoadedOnce(true); - } catch (err) { - if (cancelled) return; - // Notify the error and still show an empty editor so the user isn't stuck - showNotice.error(err); - - // Align refs with fallback text after a load failure - prevDataRef.current = ""; - currDataRef.current = ""; - - setInitialText(""); - const pathParts = [String(dataKey ?? nanoid()), instanceIdRef.current]; - pathParts.push(language); - - setModelPath(pathParts.join(".")); - // Mark refresh failure so users can retry - setRefreshFailed(err ?? true); - // Initial load failed; keep `hasLoadedOnce` false to prevent accidental save - // of an empty buffer. It will be enabled on successful refresh or first edit. - setHasLoadedOnce(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [open, dataKey, language]); - /* eslint-enable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ - - const onMount = async (editor: monaco.editor.IStandaloneCodeEditor) => { - editorRef.current = editor; - // Dispose previous model when switching (monaco-react creates a fresh model when `path` changes) - modelChangeDisposableRef.current?.dispose(); - modelChangeDisposableRef.current = editor.onDidChangeModel((e) => { - if (e.oldModelUrl) { - const oldModel = monaco.editor.getModel(e.oldModelUrl); - oldModel?.dispose(); - } - }); - // No refresh on mount; doing so would double-load. - // Background refreshes are handled by the [open, modelPath, dataKey] effect. - }; - - const handleChange = useLockFn(async (value?: string) => { - try { - currDataRef.current = value ?? editorRef.current?.getValue(); - onChange?.(prevDataRef.current, currDataRef.current); - // If the initial load failed, allow saving after the user makes an edit. - if (!hasLoadedOnce) { - setHasLoadedOnce(true); - } - } catch (err) { - showNotice.error(err); - } - }); const handleSave = useLockFn(async () => { try { - // Disallow saving if initial content never loaded successfully to avoid accidental overwrite - if (!readOnly && hasLoadedOnce) { - // Guard: if the editor/model hasn't mounted, bail out - if (!editorRef.current) { - return; - } - currDataRef.current = editorRef.current.getValue(); - if (onSave) { - await onSave(prevDataRef.current, currDataRef.current); - // If save succeeds, align prev with current - prevDataRef.current = currDataRef.current; - } + if (!readOnly) { + await onSave?.(); } onClose(); - } catch (err) { - showNotice.error(err); + } catch (error) { + showNotice.error(error); } }); - // Explicit paste action: works even when Monaco's context-menu paste cannot read clipboard. + const handleClose = () => { + try { + onClose(); + } catch (error) { + showNotice.error(error); + } + }; + const handlePaste = useLockFn(async () => { try { - if (!editorRef.current || effectiveReadOnly) return; + if (readOnly || loading || !editorRef.current) return; + const text = await navigator.clipboard.readText(); if (!text) return; - const editor = editorRef.current; - const model = editor.getModel(); - const selections = editor.getSelections(); + + const editorInstance = editorRef.current; + const model = editorInstance.getModel(); + const selections = editorInstance.getSelections(); if (!model || !selections || selections.length === 0) return; - // Group edits to allow single undo step - editor.pushUndoStop(); - editor.executeEdits( + + editorInstance.pushUndoStop(); + editorInstance.executeEdits( "explicit-paste", - selections.map((sel) => ({ - range: sel, + selections.map((selection) => ({ + range: selection, text, forceMoveMarkers: true, })), ); - editor.pushUndoStop(); - editor.focus(); - } catch (err) { - showNotice.error(err); + editorInstance.pushUndoStop(); + editorInstance.focus(); + } catch (error) { + showNotice.error(error); } }); - const handleClose = useLockFn(async () => { + const handleFormat = useLockFn(async () => { try { - onClose(); - } catch (err) { - showNotice.error(err); + if (loading) return; + await editorRef.current?.getAction("editor.action.formatDocument")?.run(); + } catch (error) { + showNotice.error(error); + } + }); + + const handleToggleMaximize = useLockFn(async () => { + try { + await appWindow.toggleMaximize(); + await syncMaximizedState(); + editorRef.current?.layout(); + } catch (error) { + showNotice.error(error); } }); useEffect(() => { + if (!open) return; + void syncMaximizedState(); + }, [open, syncMaximizedState]); + + useEffect(() => { + if (!open) return; + const onResized = debounce(() => { - appWindow - .isMaximized() - .then((maximized) => setIsMaximized(() => maximized)); - // Ensure Monaco recalculates layout after window resize/maximize/restore. - // automaticLayout is not always sufficient when the parent dialog resizes. + void syncMaximizedState(); try { editorRef.current?.layout(); - } catch {} + } catch { + // Ignore transient layout errors during window transitions. + } }, 100); + const unlistenResized = appWindow.onResized(onResized); return () => { - unlistenResized.then((fn) => fn()); - // Clean up editor and model to avoid leaks - const model = editorRef.current?.getModel(); - editorRef.current?.dispose(); - model?.dispose(); - modelChangeDisposableRef.current?.dispose(); - modelChangeDisposableRef.current = null; - editorRef.current = undefined; + unlistenResized.then((unlisten) => unlisten()); }; - }, []); + }, [open, syncMaximizedState]); return ( (props: Props) => { (props: Props) => { }} >
- {/* Show overlay while loading or until modelPath is ready */} - - {/* Background refresh failure helper */} - {!!refreshFailed && ( -
- - {t("shared.feedback.notifications.common.refreshFailed")} - - -
- )} - {initialText !== null && modelPath && ( + + {!loading && ( { + editorRef.current = editorInstance; + }} + onChange={(nextValue) => onChange?.(nextValue ?? "")} + onValidate={onValidate} options={{ automaticLayout: true, - tabSize: ["yaml", "javascript", "css"].includes(language) - ? 2 - : 4, + tabSize: 2, minimap: { - enabled: document.documentElement.clientWidth >= 1500, + enabled: + typeof document !== "undefined" && + document.documentElement.clientWidth >= 1500, }, mouseWheelZoom: true, - readOnly: effectiveReadOnly, + readOnly, readOnlyMessage: { value: t("profiles.modals.editor.messages.readOnly"), }, @@ -467,7 +231,7 @@ export const EditorViewer = (props: Props) => { other: true, }, padding: { - top: 33, // Top padding to prevent snippet overlap + top: 33, }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${ getSystem() === "windows" ? ", twemoji mozilla" : "" @@ -475,9 +239,6 @@ export const EditorViewer = (props: Props) => { fontLigatures: false, smoothScrolling: true, }} - beforeMount={beforeMount} - onMount={onMount} - onChange={handleChange} /> )}
@@ -491,8 +252,10 @@ export const EditorViewer = (props: Props) => { color="inherit" sx={{ display: readOnly ? "none" : "" }} title={t("profiles.page.importForm.actions.paste")} - disabled={isLoading} - onClick={() => handlePaste()} + disabled={loading} + onClick={() => { + void handlePaste(); + }} > @@ -501,12 +264,10 @@ export const EditorViewer = (props: Props) => { color="inherit" sx={{ display: readOnly ? "none" : "" }} title={t("profiles.modals.editor.actions.format")} - disabled={isLoading} - onClick={() => - editorRef.current - ?.getAction("editor.action.formatDocument") - ?.run() - } + disabled={loading} + onClick={() => { + void handleFormat(); + }} > @@ -516,21 +277,9 @@ export const EditorViewer = (props: Props) => { title={t( isMaximized ? "shared.window.minimize" : "shared.window.maximize", )} - onClick={() => - appWindow - .toggleMaximize() - .then(() => - appWindow - .isMaximized() - .then((maximized) => setIsMaximized(maximized)), - ) - .finally(() => { - // Nudge a layout in case the resize event batching lags behind - try { - editorRef.current?.layout(); - } catch {} - }) - } + onClick={() => { + void handleToggleMaximize(); + }} > {isMaximized ? : } @@ -543,9 +292,11 @@ export const EditorViewer = (props: Props) => { {!readOnly && ( diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 7d20e613b..e5767d10e 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -19,7 +19,7 @@ import { import { open } from "@tauri-apps/plugin-shell"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; -import { useEffect, useReducer, useState } from "react"; +import { useCallback, useEffect, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; import { mutate } from "swr"; @@ -27,6 +27,7 @@ import { ConfirmViewer } from "@/components/profile/confirm-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer"; import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer"; +import { useEditorDocument } from "@/hooks/use-editor-document"; import { viewProfile, readProfileFile, @@ -277,6 +278,29 @@ export const ProfileItem = (props: Props) => { const [scriptOpen, setScriptOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid]); + const loadMergeDocument = useCallback( + () => readProfileFile(option?.merge ?? ""), + [option?.merge], + ); + const loadScriptDocument = useCallback( + () => readProfileFile(option?.script ?? ""), + [option?.script], + ); + + const profileDocument = useEditorDocument({ + open: fileOpen, + load: loadProfileDocument, + }); + const mergeDocument = useEditorDocument({ + open: mergeOpen, + load: loadMergeDocument, + }); + const scriptDocument = useEditorDocument({ + open: scriptOpen, + load: loadScriptDocument, + }); + const onOpenHome = () => { setAnchorEl(null); open(itemData.home ?? ""); @@ -575,6 +599,29 @@ export const ProfileItem = (props: Props) => { }; }, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]); + const handleSaveProfileDocument = useLockFn(async () => { + const currentValue = profileDocument.value; + await saveProfileFile(uid, currentValue); + onSave?.(profileDocument.savedValue, currentValue); + profileDocument.markSaved(currentValue); + }); + + const handleSaveMergeDocument = useLockFn(async () => { + const mergeUid = option?.merge ?? ""; + const currentValue = mergeDocument.value; + await saveProfileFile(mergeUid, currentValue); + onSave?.(mergeDocument.savedValue, currentValue); + mergeDocument.markSaved(currentValue); + }); + + const handleSaveScriptDocument = useLockFn(async () => { + const scriptUid = option?.script ?? ""; + const currentValue = scriptDocument.value; + await saveProfileFile(scriptUid, currentValue); + onSave?.(scriptDocument.savedValue, currentValue); + scriptDocument.markSaved(currentValue); + }); + return ( { {fileOpen && ( readProfileFile(uid)} - dataKey={uid} + value={profileDocument.value} language="yaml" - onSave={async (prev, curr) => { - await saveProfileFile(uid, curr ?? ""); - onSave?.(prev, curr); - }} + path={`profile:${uid}.yaml`} + loading={profileDocument.loading} + dirty={profileDocument.dirty} + onChange={profileDocument.setValue} + onSave={handleSaveProfileDocument} onClose={() => setFileOpen(false)} /> )} @@ -877,26 +924,26 @@ export const ProfileItem = (props: Props) => { {mergeOpen && ( readProfileFile(option?.merge ?? "")} - dataKey={`merge:${option?.merge ?? ""}`} + value={mergeDocument.value} language="yaml" - onSave={async (prev, curr) => { - await saveProfileFile(option?.merge ?? "", curr ?? ""); - onSave?.(prev, curr); - }} + path={`merge:${option?.merge ?? ""}.yaml`} + loading={mergeDocument.loading} + dirty={mergeDocument.dirty} + onChange={mergeDocument.setValue} + onSave={handleSaveMergeDocument} onClose={() => setMergeOpen(false)} /> )} {scriptOpen && ( readProfileFile(option?.script ?? "")} - dataKey={`script:${option?.script ?? ""}`} + value={scriptDocument.value} language="javascript" - onSave={async (prev, curr) => { - await saveProfileFile(option?.script ?? "", curr ?? ""); - onSave?.(prev, curr); - }} + path={`script:${option?.script ?? ""}.js`} + loading={scriptDocument.loading} + dirty={scriptDocument.dirty} + onChange={scriptDocument.setValue} + onSave={handleSaveScriptDocument} onClose={() => setScriptOpen(false)} /> )} diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index c607fa47d..d296e53b8 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -3,16 +3,17 @@ import { Box, Badge, Chip, - Typography, - MenuItem, - Menu, IconButton, + Menu, + MenuItem, + Typography, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditorViewer } from "@/components/profile/editor-viewer"; +import { useEditorDocument } from "@/hooks/use-editor-document"; import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds"; import { showNotice } from "@/services/notice-service"; @@ -38,6 +39,12 @@ export const ProfileMore = (props: Props) => { const [fileOpen, setFileOpen] = useState(false); const [logOpen, setLogOpen] = useState(false); + const loadDocument = useCallback(() => readProfileFile(id), [id]); + const document = useEditorDocument({ + open: fileOpen, + load: loadDocument, + }); + const onEditFile = () => { setAnchorEl(null); setFileOpen(true); @@ -77,6 +84,13 @@ export const ProfileMore = (props: Props) => { lineHeight: 1, }; + const handleSave = useLockFn(async () => { + const currentValue = document.value; + await saveProfileFile(id, currentValue); + onSave?.(document.savedValue, currentValue); + document.markSaved(currentValue); + }); + return ( <> { readProfileFile(id)} - dataKey={id} + value={document.value} language={id === "Merge" ? "yaml" : "javascript"} - onSave={async (prev, curr) => { - await saveProfileFile(id, curr ?? ""); - onSave?.(prev, curr); - }} + path={`profile-more:${id}.${id === "Merge" ? "yaml" : "js"}`} + loading={document.loading} + dirty={document.dirty} + onChange={document.setValue} + onSave={handleSave} onClose={() => setFileOpen(false)} /> )} diff --git a/src/components/setting/mods/config-viewer.tsx b/src/components/setting/mods/config-viewer.tsx index f71d809e7..fc4c415e1 100644 --- a/src/components/setting/mods/config-viewer.tsx +++ b/src/components/setting/mods/config-viewer.tsx @@ -9,14 +9,24 @@ import { getRuntimeYaml } from "@/services/cmds"; export const ConfigViewer = forwardRef((_, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); const [runtimeConfig, setRuntimeConfig] = useState(""); useImperativeHandle(ref, () => ({ open: () => { - getRuntimeYaml().then((data) => { - setRuntimeConfig(data ?? "# Error getting runtime yaml\n"); - setOpen(true); - }); + setRuntimeConfig(""); + setLoading(true); + setOpen(true); + getRuntimeYaml() + .then((data) => { + setRuntimeConfig(data ?? "# Error getting runtime yaml\n"); + }) + .catch(() => { + setRuntimeConfig("# Error getting runtime yaml\n"); + }) + .finally(() => { + setLoading(false); + }); }, close: () => setOpen(false), })); @@ -31,10 +41,11 @@ export const ConfigViewer = forwardRef((_, ref) => { } - initialData={() => Promise.resolve(runtimeConfig)} - dataKey="runtime-config" + value={runtimeConfig} readOnly language="yaml" + path="runtime-config.yaml" + loading={loading} onClose={() => setOpen(false)} /> ); diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index ee2d06105..34cc90ab3 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -102,6 +102,8 @@ export const SysproxyViewer = forwardRef((props, ref) => { const [open, setOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [pacEditorValue, setPacEditorValue] = useState(DEFAULT_PAC); + const [pacEditorSavedValue, setPacEditorSavedValue] = useState(DEFAULT_PAC); const [saving, setSaving] = useState(false); const { verge, patchVerge, mutateVerge } = useVerge(); const [hostOptions, setHostOptions] = useState([]); @@ -218,6 +220,21 @@ export const SysproxyViewer = forwardRef((props, ref) => { ? !validReg.test(value.bypass) : false; + const openPacEditor = () => { + const nextPac = value.pac_content ?? DEFAULT_PAC; + setPacEditorValue(nextPac); + setPacEditorSavedValue(nextPac); + setEditorOpen(true); + }; + + const handleSavePac = useLockFn(async () => { + const nextPac = + pacEditorValue.trim().length > 0 ? pacEditorValue : DEFAULT_PAC; + + setValue((current) => ({ ...current, pac_content: nextPac })); + setPacEditorSavedValue(nextPac); + }); + useImperativeHandle(ref, () => ({ open: () => { setOpen(true); @@ -665,9 +682,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { @@ -675,16 +690,12 @@ export const SysproxyViewer = forwardRef((props, ref) => { Promise.resolve(value.pac_content ?? "")} - dataKey="sysproxy-pac" + value={pacEditorValue} language="javascript" - onSave={(_prev, curr) => { - let pac = DEFAULT_PAC; - if (curr && curr.trim().length > 0) { - pac = curr; - } - setValue((v) => ({ ...v, pac_content: pac })); - }} + path="sysproxy-pac.js" + dirty={pacEditorValue !== pacEditorSavedValue} + onChange={setPacEditorValue} + onSave={handleSavePac} onClose={() => setEditorOpen(false)} /> )} diff --git a/src/components/setting/mods/theme-viewer.tsx b/src/components/setting/mods/theme-viewer.tsx index ee8be65ce..be1f17183 100644 --- a/src/components/setting/mods/theme-viewer.tsx +++ b/src/components/setting/mods/theme-viewer.tsx @@ -15,7 +15,6 @@ import { useMemo, useRef, useState, - useCallback, } from "react"; import { useTranslation } from "react-i18next"; @@ -31,6 +30,8 @@ export function ThemeViewer(props: { ref?: React.Ref }) { const [open, setOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [cssEditorValue, setCssEditorValue] = useState(""); + const [cssEditorSavedValue, setCssEditorSavedValue] = useState(""); const { verge, patchVerge } = useVerge(); const { theme_setting } = verge ?? {}; const [theme, setTheme] = useState(theme_setting || {}); @@ -111,12 +112,18 @@ export function ThemeViewer(props: { ref?: React.Ref }) { [], ); - // Stable loader that returns a fresh Promise each call so EditorViewer - // can retry/refresh and always read the latest staged CSS from state. - const loadCss = useCallback( - () => Promise.resolve(themeRef.current?.css_injection ?? ""), - [], - ); + const openCssEditor = () => { + const nextCss = themeRef.current?.css_injection ?? ""; + setCssEditorValue(nextCss); + setCssEditorSavedValue(nextCss); + setEditorOpen(true); + }; + + const handleSaveCss = useLockFn(async () => { + const prevTheme = themeRef.current || {}; + setTheme({ ...prevTheme, css_injection: cssEditorValue }); + setCssEditorSavedValue(cssEditorValue); + }); const renderItem = (labelKey: string, key: ThemeKey) => { const label = t(labelKey); @@ -167,9 +174,7 @@ export function ThemeViewer(props: { ref?: React.Ref }) { @@ -177,16 +182,12 @@ export function ThemeViewer(props: { ref?: React.Ref }) { { - // Only stage the CSS change locally. Persistence happens - // when the outer Theme dialog's Save button is pressed. - const prevTheme = themeRef.current || {}; - const nextCss = curr ?? ""; - setTheme({ ...prevTheme, css_injection: nextCss }); - }} + path="theme-css.css" + dirty={cssEditorValue !== cssEditorSavedValue} + onChange={setCssEditorValue} + onSave={handleSaveCss} onClose={() => { setEditorOpen(false); }} diff --git a/src/hooks/use-editor-document.ts b/src/hooks/use-editor-document.ts new file mode 100644 index 000000000..1d3f5afaa --- /dev/null +++ b/src/hooks/use-editor-document.ts @@ -0,0 +1,61 @@ +/* eslint-disable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ +import { useEffect } from "foxact/use-abortable-effect"; +import { useCallback, useState } from "react"; + +import { showNotice } from "@/services/notice-service"; + +interface UseEditorDocumentOptions { + open: boolean; + load: () => Promise; +} + +export const useEditorDocument = ({ open, load }: UseEditorDocumentOptions) => { + const [value, setValue] = useState(""); + const [savedValue, setSavedValue] = useState(""); + const [loading, setLoading] = useState(true); + + const resetDocumentState = useCallback(() => { + setValue(""); + setSavedValue(""); + setLoading(true); + }, []); + + useEffect( + (signal) => { + resetDocumentState(); + + if (!open) return; + + load() + .then((nextValue) => { + if (signal.aborted) return; + + const normalized = nextValue ?? ""; + setValue(normalized); + setSavedValue(normalized); + }) + .catch((error) => { + if (!signal.aborted) showNotice.error(error); + }) + .finally(() => { + if (!signal.aborted) setLoading(false); + }); + }, + [load, open, resetDocumentState], + ); + + const markSaved = useCallback((nextValue: string) => { + setSavedValue(nextValue); + }, []); + + const dirty = value !== savedValue; + + return { + value, + setValue, + savedValue, + loading, + dirty, + markSaved, + }; +}; diff --git a/src/main.tsx b/src/main.tsx index 6680f8812..5d37c61a6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ /// /// import "./assets/styles/index.scss"; -import "./utils/monaco"; +import "./services/monaco"; import { ResizeObserver } from "@juggle/resize-observer"; import { ComposeContextProvider } from "foxact/compose-context-provider"; diff --git a/src/services/monaco.ts b/src/services/monaco.ts new file mode 100644 index 000000000..192212ada --- /dev/null +++ b/src/services/monaco.ts @@ -0,0 +1,70 @@ +import { loader } from "@monaco-editor/react"; +import metaSchema from "meta-json-schema/schemas/meta-json-schema.json"; +import * as monaco from "monaco-editor"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; +import { configureMonacoYaml, JSONSchema } from "monaco-yaml"; +import pac from "types-pac/pac.d.ts?raw"; + +import yamlWorker from "@/utils/yaml.worker?worker"; + +self.MonacoEnvironment = { + getWorker(_, label) { + switch (label) { + case "css": + case "less": + case "scss": + return new cssWorker(); + case "typescript": + case "javascript": + return new tsWorker(); + case "yaml": + return new yamlWorker(); + default: + return new editorWorker(); + } + }, +}; + +loader.config({ monaco }); + +// Work around https://github.com/remcohaszing/monaco-yaml/issues/272. +const patchCreateWebWorker = () => { + const oldCreateWebWorker = monaco.editor.createWebWorker; + + monaco.editor.createWebWorker = ( + options: monaco.IWebWorkerOptions | monaco.editor.IInternalWebWorkerOptions, + ) => { + if ("worker" in options) { + return oldCreateWebWorker(options); + } + + return monaco.createWebWorker(options); + }; +}; + +let mounted = false; + +export const beforeEditorMount = () => { + if (mounted) return; + + patchCreateWebWorker(); + + monaco.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); + + configureMonacoYaml(monaco, { + validate: true, + enableSchemaRequest: true, + completion: true, + schemas: [ + { + uri: "http://example.com/meta-json-schema.json", + fileMatch: ["**/*.yaml", "**/*.yml"], + schema: metaSchema as unknown as JSONSchema, // JSON import is inferred as a literal type + }, + ], + }); + + mounted = true; +}; diff --git a/src/utils/monaco.ts b/src/utils/monaco.ts deleted file mode 100644 index 13b9ade6b..000000000 --- a/src/utils/monaco.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { loader } from "@monaco-editor/react"; -import * as monaco from "monaco-editor"; -import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; -import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; -import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; -import yamlWorker from "monaco-yaml/yaml.worker?worker"; - -self.MonacoEnvironment = { - getWorker(_, label) { - switch (label) { - case "css": - case "less": - case "scss": - return new cssWorker(); - case "typescript": - case "javascript": - return new tsWorker(); - case "yaml": - return new yamlWorker(); - default: - return new editorWorker(); - } - }, -}; - -loader.config({ monaco }); - -loader.init().catch((error) => { - console.error("[monaco] Monaco initialization failed:", error); -}); diff --git a/src/utils/yaml.worker.ts b/src/utils/yaml.worker.ts new file mode 100644 index 000000000..799d9386b --- /dev/null +++ b/src/utils/yaml.worker.ts @@ -0,0 +1,2 @@ +// See https://github.com/remcohaszing/monaco-yaml?tab=readme-ov-file#why-doesnt-it-work-with-vite +import "monaco-yaml/yaml.worker.js";