diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1b2df8b..eb29d50 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod backup; pub mod disabled; pub mod fs; +pub mod profiles; pub mod registry; pub mod scanner; pub mod system; diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs new file mode 100644 index 0000000..5562dba --- /dev/null +++ b/src-tauri/src/commands/profiles.rs @@ -0,0 +1,147 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +fn profiles_dir() -> PathBuf { + dirs::data_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".patheditor") + .join("profiles") +} + +fn profile_path(name: &str) -> PathBuf { + profiles_dir().join(format!("{}.json", name)) +} + +/// 内部用的 PathEntry(与前端 PathEntry 字段一致) +#[derive(Serialize, Deserialize, Clone)] +pub struct ProfilePathEntry { + pub path: String, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct ProfileMeta { + pub name: String, + pub created: String, + pub modified: String, +} + +#[derive(Serialize, Deserialize)] +pub struct ProfileData { + pub name: String, + pub sys: Vec, + pub user: Vec, + pub created: String, + pub modified: String, +} + +/// 列出所有配置文件的元数据 +#[tauri::command] +pub fn list_profiles() -> Result, String> { + let dir = profiles_dir(); + if !dir.exists() { + return Ok(vec![]); + } + + let mut profiles: Vec = Vec::new(); + let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(true, |e| e != "json") { + continue; + } + let content = fs::read_to_string(&path) + .map_err(|e| format!("无法读取 {}: {}", path.display(), e))?; + if let Ok(data) = serde_json::from_str::(&content) { + profiles.push(ProfileMeta { + name: data.name, + created: data.created, + modified: data.modified, + }); + } + } + + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(profiles) +} + +/// 保存当前 PATH 为配置文件 +#[tauri::command] +pub fn save_profile( + name: String, + sys: Vec, + user: Vec, +) -> Result<(), String> { + let dir = profiles_dir(); + fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?; + + let path = profile_path(&name); + let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + + let data = ProfileData { + name, + sys, + user, + created: now.clone(), + modified: now, + }; + + let json = + serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?; + fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; + + log::info!("已保存配置: {}", path.display()); + Ok(()) +} + +/// 加载配置文件 +#[tauri::command] +pub fn load_profile(name: String) -> Result { + let path = profile_path(&name); + if !path.exists() { + return Err(format!("配置文件不存在: {}", name)); + } + let content = fs::read_to_string(&path) + .map_err(|e| format!("无法读取配置文件: {}", e))?; + serde_json::from_str(&content) + .map_err(|e| format!("JSON 解析失败: {}", e)) +} + +/// 删除配置文件 +#[tauri::command] +pub fn delete_profile(name: String) -> Result<(), String> { + let path = profile_path(&name); + fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?; + log::info!("已删除配置: {}", path.display()); + Ok(()) +} + +/// 重命名配置文件 +#[tauri::command] +pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { + let old_path = profile_path(&old_name); + if !old_path.exists() { + return Err(format!("配置文件不存在: {}", old_name)); + } + + let mut data: ProfileData = + serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?; + + data.name = new_name.clone(); + data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + + let new_path = profile_path(&new_name); + let json = + serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?; + fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; + + if old_path != new_path { + fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?; + } + + log::info!("已重命名配置: {} -> {}", old_name, new_name); + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d70d1d6..2bcbe97 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,11 @@ pub fn run() { commands::disabled::load_disabled_state, commands::scanner::scan_conflicts, commands::scanner::scan_tools, + commands::profiles::list_profiles, + commands::profiles::save_profile, + commands::profiles::load_profile, + commands::profiles::delete_profile, + commands::profiles::rename_profile, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/dialogs/ProfileDialog.tsx b/src/components/dialogs/ProfileDialog.tsx new file mode 100644 index 0000000..b744216 --- /dev/null +++ b/src/components/dialogs/ProfileDialog.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { useAppStore } from '@/store/app-store'; +import type { PathEntry } from '@/core/path-entry'; + +interface ProfileMeta { + name: string; + created: string; + modified: string; +} + +interface ProfileData { + name: string; + sys: PathEntry[]; + user: PathEntry[]; + created: string; + modified: string; +} + +interface Props { + open: boolean; + onClose: () => void; +} + +export function ProfileDialog({ open, onClose }: Props) { + const { t } = useTranslation(); + const [profiles, setProfiles] = useState([]); + const [newName, setNewName] = useState(''); + const [selected, setSelected] = useState(null); + const [selectedData, setSelectedData] = useState(null); + const [saving, setSaving] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(''); + + const refreshProfiles = useCallback(async () => { + const list = await invoke('list_profiles'); + setProfiles(list); + }, []); + + useEffect(() => { + if (open) refreshProfiles(); + }, [open, refreshProfiles]); + + const handleSave = async () => { + if (!newName.trim()) return; + setSaving(true); + const { sysPaths, userPaths } = useAppStore.getState(); + await invoke('save_profile', { name: newName.trim(), sys: sysPaths, user: userPaths }); + setNewName(''); + setSaving(false); + refreshProfiles(); + }; + + const handleLoad = async (name: string) => { + const data = await invoke('load_profile', { name }); + setSelected(name); + setSelectedData(data); + }; + + const handleApply = async () => { + if (!selected || !selectedData) return; + if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return; + useAppStore.getState().replaceBothPaths( + selectedData.sys.map(e => e.path), + selectedData.user.map(e => e.path), + ); + // 同步 disabled 状态 + await invoke('save_disabled_state', { + system: selectedData.sys.filter(e => !e.enabled).map(e => e.path), + user: selectedData.user.filter(e => !e.enabled).map(e => e.path), + }); + await useAppStore.getState().savePaths(); + onClose(); + }; + + const handleDelete = async (name: string) => { + if (!window.confirm(`删除配置文件 "${name}"?`)) return; + await invoke('delete_profile', { name }); + if (selected === name) { setSelected(null); setSelectedData(null); } + refreshProfiles(); + }; + + const handleRename = async () => { + if (!selected || !renameValue.trim()) return; + await invoke('rename_profile', { oldName: selected, newName: renameValue.trim() }); + setRenameOpen(false); + setSelected(renameValue.trim()); + refreshProfiles(); + }; + + return ( + +
+
+

{t('profile.title')}

+
+ setNewName(e.target.value)} + placeholder={t('profile.namePlaceholder')} + className="px-2 py-1 text-sm rounded border outline-none w-44" + style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }} + /> + +
+
+ +
+ {/* 左侧:列表 */} +
+ {profiles.length === 0 ? ( +
{t('profile.noProfiles')}
+ ) : ( + profiles.map(p => ( +
handleLoad(p.name)} + className="px-2 py-1.5 text-sm rounded cursor-pointer mb-0.5" + style={{ + backgroundColor: selected === p.name ? 'rgba(59,130,246,0.15)' : 'transparent', + color: selected === p.name ? '#3b82f6' : 'var(--app-fg)', + }} + > + {p.name} +
+ )) + )} +
+ + {/* 右侧:详情 */} +
+ {!selectedData ? ( +
+ {profiles.length === 0 ? t('profile.noProfiles') : '选择一个配置文件'} +
+ ) : ( +
+
+ {selectedData.name} + {selectedData.modified} +
+ +
+ + + +
+ + {renameOpen && ( +
+ setRenameValue(e.target.value)} + className="px-2 py-1 text-xs rounded border outline-none" + style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }} + /> + +
+ )} + + + +
+ )} +
+
+
+
+ ); +} + +function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) { + return ( +
+
{title}
+ {paths.length === 0 ? ( +
(空)
+ ) : ( +
+ {paths.map((e, i) => ( +
+ + {e.enabled ? '●' : '○'} + + {e.path} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 0478ee6..abfb008 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -13,6 +13,7 @@ import { PathEditDialog } from '@/components/dialogs/PathEditDialog'; import { HelpDialog } from '@/components/dialogs/HelpDialog'; import { ImportDialog } from '@/components/dialogs/ImportDialog'; import { AnalyzeDialog } from '@/components/dialogs/AnalyzeDialog'; +import { ProfileDialog } from '@/components/dialogs/ProfileDialog'; import { useAppActions, type DialogState } from '@/hooks/use-app-actions'; /** Tauri's File object includes the native filesystem path */ @@ -35,10 +36,11 @@ export function AppShell() { open: false, system: [], user: [], }); const [analyzeOpen, setAnalyzeOpen] = useState(false); + const [profilesOpen, setProfilesOpen] = useState(false); const actions = useAppActions(activeTab, { editDialog, newDialog, helpOpen, importDialog, - setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, + setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen, }); const tabConfig: { id: TabId; label: string }[] = [ @@ -86,6 +88,7 @@ export function AppShell() { const current = localStorage.getItem('i18nextLng') || 'zh-CN'; i18n.changeLanguage(current === 'zh-CN' ? 'en' : 'zh-CN'); }} + onProfiles={() => setProfilesOpen(true)} onAnalyze={() => setAnalyzeOpen(true)} onDarkMode={() => useThemeStore.getState().toggle()} /> @@ -116,6 +119,7 @@ export function AppShell() { setHelpOpen(false)} /> setImportDialog({ open: false, system: [], user: [] })} /> setAnalyzeOpen(false)} /> + setProfilesOpen(false)} /> ); } diff --git a/src/components/toolbar/ToolBar.tsx b/src/components/toolbar/ToolBar.tsx index 6b6d28a..f45b412 100644 --- a/src/components/toolbar/ToolBar.tsx +++ b/src/components/toolbar/ToolBar.tsx @@ -21,6 +21,7 @@ interface ToolBarProps { onLanguage: () => void; onDarkMode: () => void; onAnalyze: () => void; + onProfiles: () => void; } export function ToolBar(props: ToolBarProps) { @@ -70,6 +71,9 @@ export function ToolBar(props: ToolBarProps) { + diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index e5b5163..4256894 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -20,6 +20,7 @@ export interface DialogState { setHelpOpen: (v: boolean) => void; setImportDialog: (v: DialogState['importDialog']) => void; setAnalyzeOpen: (v: boolean) => void; + setProfilesOpen: (v: boolean) => void; } export function useAppActions(activeTab: TabId, dialogs: DialogState) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35f4690..2fc9d99 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -22,6 +22,7 @@ "cancel": "Cancel", "help": "Help", "analyze": "Analyze", + "profiles": "Profiles", "undo": "Undo", "redo": "Redo", "darkMode": "Dark Mode", @@ -83,6 +84,19 @@ "searchPlaceholder": "Search executable name...", "conflictCount": "{{count}} file conflict(s) found" }, + "profile": { + "title": "PATH Profiles", + "saveCurrent": "Save Current as Profile", + "namePlaceholder": "Profile name...", + "save": "Save", + "load": "Load", + "apply": "Apply", + "delete": "Delete", + "rename": "Rename", + "noProfiles": "No saved profiles", + "applyConfirm": "This will overwrite current PATH with profile \"{{name}}\" and write to registry. Confirm?", + "deleted": "Profile \"{{name}}\" deleted" + }, "help": { "content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1fcf377..fcf29b0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -22,6 +22,7 @@ "cancel": "取消", "help": "帮助", "analyze": "分析", + "profiles": "配置", "undo": "撤销", "redo": "重做", "darkMode": "深色模式", @@ -83,6 +84,19 @@ "searchPlaceholder": "搜索可执行文件名...", "conflictCount": "发现 {{count}} 个文件冲突" }, + "profile": { + "title": "PATH 配置文件", + "saveCurrent": "保存当前 PATH 为配置", + "namePlaceholder": "配置名称...", + "save": "保存", + "load": "加载", + "apply": "应用", + "delete": "删除", + "rename": "重命名", + "noProfiles": "暂无配置文件", + "applyConfirm": "将用配置 \"{{name}}\" 覆盖当前 PATH 并写入注册表,确定吗?", + "deleted": "已删除配置 \"{{name}}\"" + }, "help": { "content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" }