From f31349eaa06eaa4a2f0d16576cb9253de1947861 Mon Sep 17 00:00:00 2001
From: GyDi <segydi@foxmail.com>
Date: Sun, 27 Mar 2022 00:58:17 +0800
Subject: [PATCH] feat: supports edit profile file

---
 package.json                            |  4 +-
 src-tauri/src/cmds.rs                   | 29 ++++++++
 src-tauri/src/core/profiles.rs          | 22 ++++++
 src-tauri/src/main.rs                   |  4 +-
 src/components/profile/file-editor.tsx  | 97 +++++++++++++++++++++++++
 src/components/profile/profile-item.tsx | 44 ++++++++---
 src/components/profile/profile-more.tsx | 52 +++++++++----
 src/locales/en.json                     | 11 ++-
 src/locales/zh.json                     | 11 ++-
 src/services/cmds.ts                    |  8 ++
 vite.config.ts                          |  3 +-
 yarn.lock                               | 10 +++
 12 files changed, 258 insertions(+), 37 deletions(-)
 create mode 100644 src/components/profile/file-editor.tsx

diff --git a/package.json b/package.json
index 8f33b9a..68c8baa 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "axios": "^0.26.0",
     "dayjs": "^1.10.8",
     "i18next": "^21.6.14",
+    "monaco-editor": "^0.33.0",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
     "react-i18next": "^11.15.6",
@@ -49,7 +50,8 @@
     "pretty-quick": "^3.1.3",
     "sass": "^1.49.7",
     "typescript": "^4.5.5",
-    "vite": "^2.8.6"
+    "vite": "^2.8.6",
+    "vite-plugin-monaco-editor": "^1.0.10"
   },
   "prettier": {
     "tabWidth": 2,
diff --git a/src-tauri/src/cmds.rs b/src-tauri/src/cmds.rs
index fd87647..5ba74d5 100644
--- a/src-tauri/src/cmds.rs
+++ b/src-tauri/src/cmds.rs
@@ -212,6 +212,35 @@ pub fn view_profile(index: String, profiles_state: State<'_, ProfilesState>) ->
   wrap_err!(open::that(path))
 }
 
+/// read the profile item file data
+#[tauri::command]
+pub fn read_profile_file(
+  index: String,
+  profiles_state: State<'_, ProfilesState>,
+) -> Result<String, String> {
+  let profiles = profiles_state.0.lock().unwrap();
+  let item = wrap_err!(profiles.get_item(&index))?;
+  let data = wrap_err!(item.read_file())?;
+
+  Ok(data)
+}
+
+/// save the profile item file data
+#[tauri::command]
+pub fn save_profile_file(
+  index: String,
+  file_data: Option<String>,
+  profiles_state: State<'_, ProfilesState>,
+) -> Result<(), String> {
+  if file_data.is_none() {
+    return Ok(());
+  }
+
+  let profiles = profiles_state.0.lock().unwrap();
+  let item = wrap_err!(profiles.get_item(&index))?;
+  wrap_err!(item.save_file(file_data.unwrap()))
+}
+
 /// restart the sidecar
 #[tauri::command]
 pub fn restart_sidecar(
diff --git a/src-tauri/src/core/profiles.rs b/src-tauri/src/core/profiles.rs
index 0d86bac..8311bac 100644
--- a/src-tauri/src/core/profiles.rs
+++ b/src-tauri/src/core/profiles.rs
@@ -279,6 +279,28 @@ impl PrfItem {
       file_data: Some(tmpl::ITEM_SCRIPT.into()),
     })
   }
+
+  /// get the file data
+  pub fn read_file(&self) -> Result<String> {
+    if self.file.is_none() {
+      bail!("could not find the file");
+    }
+
+    let file = self.file.clone().unwrap();
+    let path = dirs::app_profiles_dir().join(file);
+    fs::read_to_string(path).context("failed to read the file")
+  }
+
+  /// save the file data
+  pub fn save_file(&self, data: String) -> Result<()> {
+    if self.file.is_none() {
+      bail!("could not find the file");
+    }
+
+    let file = self.file.clone().unwrap();
+    let path = dirs::app_profiles_dir().join(file);
+    fs::write(path, data.as_bytes()).context("failed to save the file")
+  }
 }
 
 ///
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index fb09191..da973fd 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -118,7 +118,9 @@ fn main() -> std::io::Result<()> {
       cmds::get_profiles,
       cmds::sync_profiles,
       cmds::enhance_profiles,
-      cmds::change_profile_chain
+      cmds::change_profile_chain,
+      cmds::read_profile_file,
+      cmds::save_profile_file
     ]);
 
   #[cfg(target_os = "macos")]
diff --git a/src/components/profile/file-editor.tsx b/src/components/profile/file-editor.tsx
new file mode 100644
index 0000000..69c21a9
--- /dev/null
+++ b/src/components/profile/file-editor.tsx
@@ -0,0 +1,97 @@
+import useSWR from "swr";
+import { useEffect, useRef } from "react";
+import { useLockFn } from "ahooks";
+import { useTranslation } from "react-i18next";
+import {
+  Button,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+} from "@mui/material";
+import {
+  getVergeConfig,
+  readProfileFile,
+  saveProfileFile,
+} from "../../services/cmds";
+import Notice from "../base/base-notice";
+
+import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js";
+import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js";
+import "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js";
+import { editor } from "monaco-editor/esm/vs/editor/editor.api";
+
+interface Props {
+  uid: string;
+  open: boolean;
+  mode: "yaml" | "javascript";
+  onClose: () => void;
+  onChange?: () => void;
+}
+
+const FileEditor = (props: Props) => {
+  const { uid, open, mode, onClose, onChange } = props;
+
+  const { t } = useTranslation();
+  const editorRef = useRef<any>();
+  const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
+  const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
+  const { theme_mode } = vergeConfig ?? {};
+
+  useEffect(() => {
+    if (!open) return;
+
+    readProfileFile(uid).then((data) => {
+      const dom = editorRef.current;
+
+      if (!dom) return;
+      if (instanceRef.current) instanceRef.current.dispose();
+
+      instanceRef.current = editor.create(editorRef.current, {
+        value: data,
+        language: mode,
+        theme: theme_mode === "light" ? "vs" : "vs-dark",
+        minimap: { enabled: false },
+      });
+    });
+
+    return () => {
+      if (instanceRef.current) {
+        instanceRef.current.dispose();
+        instanceRef.current = null;
+      }
+    };
+  }, [open]);
+
+  const onSave = useLockFn(async () => {
+    const value = instanceRef.current?.getValue();
+
+    if (value == null) return;
+
+    try {
+      await saveProfileFile(uid, value);
+      onChange?.();
+    } catch (err: any) {
+      Notice.error(err.message || err.toString());
+    }
+  });
+
+  return (
+    <Dialog open={open} onClose={onClose}>
+      <DialogTitle>{t("Edit File")}</DialogTitle>
+
+      <DialogContent sx={{ width: 520, pb: 1 }}>
+        <div style={{ width: "100%", height: "420px" }} ref={editorRef} />
+      </DialogContent>
+
+      <DialogActions>
+        <Button onClick={onClose}>{t("Cancel")}</Button>
+        <Button onClick={onSave} variant="contained">
+          {t("Save")}
+        </Button>
+      </DialogActions>
+    </Dialog>
+  );
+};
+
+export default FileEditor;
diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx
index c6a2893..8e29d76 100644
--- a/src/components/profile/profile-item.tsx
+++ b/src/components/profile/profile-item.tsx
@@ -21,6 +21,7 @@ import { atomLoadingCache } from "../../services/states";
 import { updateProfile, deleteProfile, viewProfile } from "../../services/cmds";
 import parseTraffic from "../../utils/parse-traffic";
 import ProfileEdit from "./profile-edit";
+import FileEditor from "./file-editor";
 import Notice from "../base/base-notice";
 
 const Wrapper = styled(Box)(({ theme }) => ({
@@ -54,7 +55,7 @@ const ProfileItem = (props: Props) => {
   const [position, setPosition] = useState({ left: 0, top: 0 });
   const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
 
-  const { name = "Profile", extra, updated = 0 } = itemData;
+  const { uid, name = "Profile", extra, updated = 0 } = itemData;
   const { upload = 0, download = 0, total = 0 } = extra ?? {};
   const from = parseUrl(itemData.url);
   const expire = parseExpire(extra?.expire);
@@ -70,18 +71,16 @@ const ProfileItem = (props: Props) => {
   const loading = loadingCache[itemData.uid] ?? false;
 
   const [editOpen, setEditOpen] = useState(false);
-  const onEdit = () => {
+  const [fileOpen, setFileOpen] = useState(false);
+
+  const onEditInfo = () => {
     setAnchorEl(null);
     setEditOpen(true);
   };
 
-  const onView = async () => {
+  const onEditFile = () => {
     setAnchorEl(null);
-    try {
-      await viewProfile(itemData.uid);
-    } catch (err: any) {
-      Notice.error(err?.message || err.toString());
-    }
+    setFileOpen(true);
   };
 
   const onForceSelect = () => {
@@ -89,6 +88,15 @@ const ProfileItem = (props: Props) => {
     onSelect(true);
   };
 
+  const onOpenFile = useLockFn(async () => {
+    setAnchorEl(null);
+    try {
+      await viewProfile(itemData.uid);
+    } catch (err: any) {
+      Notice.error(err?.message || err.toString());
+    }
+  });
+
   const onUpdate = useLockFn(async (withProxy: boolean) => {
     setAnchorEl(null);
     setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
@@ -122,16 +130,18 @@ const ProfileItem = (props: Props) => {
 
   const urlModeMenu = [
     { label: "Select", handler: onForceSelect },
-    { label: "Edit", handler: onEdit },
-    { label: "File", handler: onView },
+    { label: "Edit Info", handler: onEditInfo },
+    { label: "Edit File", handler: onEditFile },
+    { label: "Open File", handler: onOpenFile },
     { label: "Update", handler: () => onUpdate(false) },
     { label: "Update(Proxy)", handler: () => onUpdate(true) },
     { label: "Delete", handler: onDelete },
   ];
   const fileModeMenu = [
     { label: "Select", handler: onForceSelect },
-    { label: "Edit", handler: onEdit },
-    { label: "File", handler: onView },
+    { label: "Edit Info", handler: onEditInfo },
+    { label: "Edit File", handler: onEditFile },
+    { label: "Open File", handler: onOpenFile },
     { label: "Delete", handler: onDelete },
   ];
 
@@ -256,6 +266,7 @@ const ProfileItem = (props: Props) => {
         onClose={() => setAnchorEl(null)}
         anchorPosition={position}
         anchorReference="anchorPosition"
+        transitionDuration={225}
         onContextMenu={(e) => {
           setAnchorEl(null);
           e.preventDefault();
@@ -279,6 +290,15 @@ const ProfileItem = (props: Props) => {
           onClose={() => setEditOpen(false)}
         />
       )}
+
+      {fileOpen && (
+        <FileEditor
+          uid={uid}
+          open={fileOpen}
+          mode="yaml"
+          onClose={() => setFileOpen(false)}
+        />
+      )}
     </>
   );
 };
diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx
index 22f5b5d..57a9c07 100644
--- a/src/components/profile/profile-more.tsx
+++ b/src/components/profile/profile-more.tsx
@@ -1,6 +1,7 @@
 import dayjs from "dayjs";
 import { useEffect, useState } from "react";
 import { useTranslation } from "react-i18next";
+import { useLockFn } from "ahooks";
 import {
   alpha,
   Box,
@@ -12,9 +13,10 @@ import {
 } from "@mui/material";
 import { CmdType } from "../../services/types";
 import { viewProfile } from "../../services/cmds";
+import enhance from "../../services/enhance";
 import ProfileEdit from "./profile-edit";
+import FileEditor from "./file-editor";
 import Notice from "../base/base-notice";
-import enhance from "../../services/enhance";
 
 const Wrapper = styled(Box)(({ theme }) => ({
   width: "100%",
@@ -57,6 +59,7 @@ const ProfileMore = (props: Props) => {
   const [anchorEl, setAnchorEl] = useState<any>(null);
   const [position, setPosition] = useState({ left: 0, top: 0 });
   const [editOpen, setEditOpen] = useState(false);
+  const [fileOpen, setFileOpen] = useState(false);
   const [status, setStatus] = useState(enhance.status(uid));
 
   // unlisten when unmount
@@ -65,40 +68,47 @@ const ProfileMore = (props: Props) => {
   // error during enhanced mode
   const hasError = selected && status?.status === "error";
 
-  const onEdit = () => {
+  const onEditInfo = () => {
     setAnchorEl(null);
     setEditOpen(true);
   };
 
-  const onView = async () => {
+  const onEditFile = () => {
+    setAnchorEl(null);
+    setFileOpen(true);
+  };
+
+  const onOpenFile = useLockFn(async () => {
     setAnchorEl(null);
     try {
       await viewProfile(itemData.uid);
     } catch (err: any) {
       Notice.error(err?.message || err.toString());
     }
-  };
+  });
 
-  const closeWrapper = (fn: () => void) => () => {
+  const fnWrapper = (fn: () => void) => () => {
     setAnchorEl(null);
     return fn();
   };
 
   const enableMenu = [
-    { label: "Disable", handler: closeWrapper(onDisable) },
-    { label: "Refresh", handler: closeWrapper(onEnhance) },
-    { label: "Edit", handler: onEdit },
-    { label: "File", handler: onView },
-    { label: "To Top", show: !hasError, handler: closeWrapper(onMoveTop) },
-    { label: "To End", show: !hasError, handler: closeWrapper(onMoveEnd) },
-    { label: "Delete", handler: closeWrapper(onDelete) },
+    { label: "Disable", handler: fnWrapper(onDisable) },
+    { label: "Refresh", handler: fnWrapper(onEnhance) },
+    { label: "Edit Info", handler: onEditInfo },
+    { label: "Edit File", handler: onEditFile },
+    { label: "Open File", handler: onOpenFile },
+    { label: "To Top", show: !hasError, handler: fnWrapper(onMoveTop) },
+    { label: "To End", show: !hasError, handler: fnWrapper(onMoveEnd) },
+    { label: "Delete", handler: fnWrapper(onDelete) },
   ];
 
   const disableMenu = [
-    { label: "Enable", handler: closeWrapper(onEnable) },
-    { label: "Edit", handler: onEdit },
-    { label: "File", handler: onView },
-    { label: "Delete", handler: closeWrapper(onDelete) },
+    { label: "Enable", handler: fnWrapper(onEnable) },
+    { label: "Edit Info", handler: onEditInfo },
+    { label: "Edit File", handler: onEditFile },
+    { label: "Open File", handler: onOpenFile },
+    { label: "Delete", handler: fnWrapper(onDelete) },
   ];
 
   const boxStyle = {
@@ -208,6 +218,7 @@ const ProfileMore = (props: Props) => {
         onClose={() => setAnchorEl(null)}
         anchorPosition={position}
         anchorReference="anchorPosition"
+        transitionDuration={225}
         onContextMenu={(e) => {
           setAnchorEl(null);
           e.preventDefault();
@@ -233,6 +244,15 @@ const ProfileMore = (props: Props) => {
           onClose={() => setEditOpen(false)}
         />
       )}
+
+      {fileOpen && (
+        <FileEditor
+          uid={uid}
+          open={fileOpen}
+          mode={type === "merge" ? "yaml" : "javascript"}
+          onClose={() => setFileOpen(false)}
+        />
+      )}
     </>
   );
 };
diff --git a/src/locales/en.json b/src/locales/en.json
index d357734..4a2601b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -19,8 +19,9 @@
   "New": "New",
   "Close All": "Close All",
   "Select": "Select",
-  "Edit": "Edit",
-  "File": "File",
+  "Edit Info": "Edit Info",
+  "Edit File": "Edit File",
+  "Open File": "Open File",
   "Update": "Update",
   "Update(Proxy)": "Update(Proxy)",
   "Delete": "Delete",
@@ -41,6 +42,7 @@
   "Clash core": "Clash core",
   "Tun Mode": "Tun Mode",
   "Auto Launch": "Auto Launch",
+  "Silent Start": "Silent Start",
   "System Proxy": "System Proxy",
   "Proxy Guard": "Proxy Guard",
   "Proxy Bypass": "Proxy Bypass",
@@ -50,5 +52,8 @@
   "Language": "Language",
   "Open App Dir": "Open App Dir",
   "Open Logs Dir": "Open Logs Dir",
-  "Version": "Version"
+  "Version": "Version",
+
+  "Save": "Save",
+  "Cancel": "Cancel"
 }
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 7ca3a32..68d1dcb 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -19,8 +19,9 @@
   "New": "新建",
   "Close All": "关闭全部",
   "Select": "使用",
-  "Edit": "编辑信息",
-  "File": "打开文件",
+  "Edit Info": "编辑信息",
+  "Edit File": "编辑文件",
+  "Open File": "打开文件",
   "Update": "æ›´æ–°",
   "Update(Proxy)": "更新(代理)",
   "Delete": "删除",
@@ -41,6 +42,7 @@
   "Clash core": "Clash 内核",
   "Tun Mode": "Tun 模式",
   "Auto Launch": "开机自启",
+  "Silent Start": "静默启动",
   "System Proxy": "系统代理",
   "Proxy Guard": "系统代理守卫",
   "Proxy Bypass": "Proxy Bypass",
@@ -50,5 +52,8 @@
   "Language": "语言设置",
   "Open App Dir": "应用目录",
   "Open Logs Dir": "日志目录",
-  "Version": "版本"
+  "Version": "版本",
+
+  "Save": "保存",
+  "Cancel": "取消"
 }
diff --git a/src/services/cmds.ts b/src/services/cmds.ts
index 06a4ecd..cf47295 100644
--- a/src/services/cmds.ts
+++ b/src/services/cmds.ts
@@ -25,6 +25,14 @@ export async function viewProfile(index: string) {
   return invoke<void>("view_profile", { index });
 }
 
+export async function readProfileFile(index: string) {
+  return invoke<string>("read_profile_file", { index });
+}
+
+export async function saveProfileFile(index: string, fileData: string) {
+  return invoke<void>("save_profile_file", { index, fileData });
+}
+
 export async function importProfile(url: string) {
   return invoke<void>("import_profile", {
     url,
diff --git a/vite.config.ts b/vite.config.ts
index 8173b2a..97daae4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,11 @@
 import { defineConfig } from "vite";
 import react from "@vitejs/plugin-react";
+import monaco from "vite-plugin-monaco-editor";
 
 // https://vitejs.dev/config/
 export default defineConfig({
   root: "src",
-  plugins: [react()],
+  plugins: [react(), monaco()],
   build: {
     outDir: "../dist",
     emptyOutDir: true,
diff --git a/yarn.lock b/yarn.lock
index a92cf69..34eb17a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1476,6 +1476,11 @@ minimist@^1.2.5:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+monaco-editor@^0.33.0:
+  version "0.33.0"
+  resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.33.0.tgz#842e244f3750a2482f8a29c676b5684e75ff34af"
+  integrity sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==
+
 mri@^1.1.5:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@@ -1922,6 +1927,11 @@ universalify@^2.0.0:
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
   integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
 
+vite-plugin-monaco-editor@^1.0.10:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.0.10.tgz#cd370f68d4121bced6f902c6284649cc8eca4170"
+  integrity sha512-7yTAFIE0SefjCmfnjrvXOl53wkxeSASc/ZIcB5tZeEK3vAmHhveV8y3f90Vp8b+PYdbUipjqf91mbFbSENkpcw==
+
 vite@^2.8.6:
   version "2.8.6"
   resolved "https://registry.yarnpkg.com/vite/-/vite-2.8.6.tgz#32d50e23c99ca31b26b8ccdc78b1d72d4d7323d3"
-- 
GitLab