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 { + 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, + 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 { + 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(); + const instanceRef = useRef(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 ( + + {t("Edit File")} + + +
+ + + + + + +
+ ); +}; + +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 && ( + 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 ProfileEdit from "./profile-edit"; -import Notice from "../base/base-notice"; import enhance from "../../services/enhance"; +import ProfileEdit from "./profile-edit"; +import FileEditor from "./file-editor"; +import Notice from "../base/base-notice"; const Wrapper = styled(Box)(({ theme }) => ({ width: "100%", @@ -57,6 +59,7 @@ const ProfileMore = (props: Props) => { const [anchorEl, setAnchorEl] = useState(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 && ( + 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("view_profile", { index }); } +export async function readProfileFile(index: string) { + return invoke("read_profile_file", { index }); +} + +export async function saveProfileFile(index: string, fileData: string) { + return invoke("save_profile_file", { index, fileData }); +} + export async function importProfile(url: string) { return invoke("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"