diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9b728e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## v4.0.0 (2026-05-26) + +### 重大变更 + +完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。 + +### 新增 + +- 现代 Web UI(React + Tailwind CSS 4 + Zustand) +- 深色/浅色模式切换 +- 中英文界面即时切换 +- 路径有效性颜色编码(红色无效、橙色重复) +- 环境变量展开悬停提示 +- 文件夹拖拽添加路径 +- 保存前 PATH 长度检查 +- 66 个前端单元测试 + 10 个 Rust 单元测试 + +### 改进 + +- 安装包体积从 ~3MB 降至 ~8MB(含 WebView2 运行时) +- 完整撤销/重做支持(8 种操作类型,50 步历史) +- JSON/CSV/TXT 三种格式导入导出 +- 合并预览查看系统+用户路径 +- 类型安全:TypeScript strict 模式 + Rust 编译期检查 + +### 移除 + +- 旧 C + IUP + Lua + gettext 代码库 +- Lua 配置引擎 → JSON 配置文件 +- gettext 国际化 → i18next + +### 已知限制 + +- 需要 Windows 10+ 系统预装的 WebView2 运行时 +- 内存占用约 50MB(旧版约 15MB) +- 文件系统路径验证在清理功能中为同步检查(不含实际目录存在性验证) diff --git a/package.json b/package.json index 3452b3c..0da262b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "v4.0", + "name": "patheditor", "private": true, - "version": "0.0.0", + "version": "4.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index fcf14c2..b9ebc80 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -6,7 +6,8 @@ use std::path::PathBuf; #[tauri::command] pub fn get_appdata_dir() -> String { dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("C:\\")) + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from(".")) .join("PathEditor") .join("backups") .to_string_lossy() @@ -33,7 +34,7 @@ pub fn backup_registry(custom_dir: Option, sys_paths: Vec, user_ .map_err(|e| format!("无法创建备份目录: {}", e))?; // 生成带时间戳的文件名 - let timestamp = Local::now().format("%Y%m%d_%H%M%S"); + let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f"); let filename = format!("path_backup_{}.txt", timestamp); let filepath = backup_dir.join(&filename); diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 85b4a58..b1f7df0 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -5,71 +5,54 @@ const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\ const USER_REG_PATH: &str = "Environment"; const PATH_VALUE: &str = "Path"; -/// 从注册表加载系统 PATH +fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result, String> { + let key = RegKey::predef(root); + let env_key = key + .open_subkey_with_flags(sub_path, KEY_READ) + .map_err(|e| format!("无法打开{}注册表项: {}", label, e))?; + + let value: String = env_key + .get_value(PATH_VALUE) + .map_err(|e| format!("无法读取{} PATH: {}", label, e))?; + + Ok(split_path(&value)) +} + +fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> { + let key = RegKey::predef(root); + let env_key = key + .open_subkey_with_flags(sub_path, KEY_WRITE) + .map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?; + + let value = join_path(paths); + env_key + .set_value(PATH_VALUE, &value) + .map_err(|e| format!("无法写入{} PATH: {}", label, e))?; + + log::info!("已保存{} PATH,{} 个条目", label, paths.len()); + Ok(()) +} + #[tauri::command] pub fn load_system_paths() -> Result, String> { - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let env_key = hklm - .open_subkey_with_flags(SYS_REG_PATH, KEY_READ) - .map_err(|e| format!("无法打开系统注册表项: {}", e))?; - - let value: String = env_key - .get_value(PATH_VALUE) - .map_err(|e| format!("无法读取系统 PATH: {}", e))?; - - Ok(split_path(&value)) + load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统") } -/// 从注册表加载用户 PATH #[tauri::command] pub fn load_user_paths() -> Result, String> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env_key = hkcu - .open_subkey_with_flags(USER_REG_PATH, KEY_READ) - .map_err(|e| format!("无法打开用户注册表项: {}", e))?; - - let value: String = env_key - .get_value(PATH_VALUE) - .map_err(|e| format!("无法读取用户 PATH: {}", e))?; - - Ok(split_path(&value)) + load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户") } -/// 保存系统 PATH 到注册表 #[tauri::command] pub fn save_system_paths(paths: Vec) -> Result<(), String> { - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let env_key = hklm - .open_subkey_with_flags(SYS_REG_PATH, KEY_WRITE) - .map_err(|e| format!("无法写入系统注册表(需要管理员权限): {}", e))?; - - let value = join_path(&paths); - env_key - .set_value(PATH_VALUE, &value) - .map_err(|e| format!("无法写入系统 PATH: {}", e))?; - - log::info!("已保存系统 PATH,{} 个条目", paths.len()); - Ok(()) + save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths) } -/// 保存用户 PATH 到注册表 #[tauri::command] pub fn save_user_paths(paths: Vec) -> Result<(), String> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env_key = hkcu - .open_subkey_with_flags(USER_REG_PATH, KEY_WRITE) - .map_err(|e| format!("无法写入用户注册表: {}", e))?; - - let value = join_path(&paths); - env_key - .set_value(PATH_VALUE, &value) - .map_err(|e| format!("无法写入用户 PATH: {}", e))?; - - log::info!("已保存用户 PATH,{} 个条目", paths.len()); - Ok(()) + save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths) } -/// 用分号分割 PATH 字符串 fn split_path(raw: &str) -> Vec { raw.split(';') .map(|s| s.trim().to_string()) @@ -77,7 +60,6 @@ fn split_path(raw: &str) -> Vec { .collect() } -/// 用分号连接路径列表(去除首尾空格避免污染注册表) fn join_path(paths: &[String]) -> String { paths .iter() @@ -86,3 +68,48 @@ fn join_path(paths: &[String]) -> String { .collect::>() .join(";") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_empty() { + assert_eq!(split_path(""), Vec::::new()); + } + + #[test] + fn split_single() { + assert_eq!(split_path("C:\\Windows"), vec!["C:\\Windows"]); + } + + #[test] + fn split_multiple() { + assert_eq!( + split_path("C:\\Windows;D:\\Projects"), + vec!["C:\\Windows", "D:\\Projects"] + ); + } + + #[test] + fn split_trims_and_filters_empty() { + assert_eq!( + split_path(" C:\\ ; ; D:\\ "), + vec!["C:\\", "D:\\"] + ); + } + + #[test] + fn join_and_split_roundtrip() { + let paths = vec!["C:\\Windows".to_string(), "D:\\Projects".to_string()]; + let joined = join_path(&paths); + let split = split_path(&joined); + assert_eq!(split, paths); + } + + #[test] + fn join_trims_entries() { + let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()]; + assert_eq!(join_path(&paths), "C:\\Windows;D:\\"); + } +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 57cf388..231fd6e 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -49,7 +49,7 @@ export function AppShell() { const list = target === TargetType.SYSTEM ? useAppStore.getState().sysPaths : useAppStore.getState().userPaths; - const value = list.get(idx); + const value = list[idx]; if (value) { setEditDialog({ open: true, index: idx, value, target }); } @@ -88,7 +88,7 @@ export function AppShell() { const handleClean = useCallback(() => { const removed = useAppStore.getState().cleanPaths( getCurrentTarget(), - () => true, // 简化版,全有效 + (p) => p.includes('%') || p.includes('\\') || p.includes('/') || /^[a-zA-Z]:[/\\]/.test(p), ); if (removed.length > 0) { useAppStore.getState().setStatusMessage( @@ -120,7 +120,7 @@ export function AppShell() { const handleExport = useCallback(() => { const state = useAppStore.getState(); - const data = { system: state.sysPaths.toArray(), user: state.userPaths.toArray() }; + const data = { system: state.sysPaths, user: state.userPaths }; const content = exportToJson(data); const mime = 'application/json'; @@ -137,8 +137,8 @@ export function AppShell() { const handleSave = useCallback(() => { const state = useAppStore.getState(); - const sysJoined = state.sysPaths.toArray().join(';'); - const userJoined = state.userPaths.toArray().join(';'); + const sysJoined = state.sysPaths.join(';'); + const userJoined = state.userPaths.join(';'); const combined = sysJoined + ';' + userJoined; const warnings: string[] = []; diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 92f588d..2f39847 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,7 +1,9 @@ import { useAppStore } from '@/store/app-store'; import { useThemeStore } from '@/store/theme-store'; +import { useTranslation } from 'react-i18next'; export function StatusBar() { + const { t } = useTranslation(); const statusMessage = useAppStore((s) => s.statusMessage); const isLoading = useAppStore((s) => s.isLoading); const isAdmin = useAppStore((s) => s.isAdmin); @@ -17,11 +19,11 @@ export function StatusBar() { color: 'var(--app-fg)', }} > - {isLoading ? '加载中...' : statusMessage} + {isLoading ? t('status.loading') : statusMessage}
- {isModified && ● 已修改} - {!isAdmin && 只读} - {isDark ? '深色' : '浅色'} + {isModified && ● {t('status.modified')}} + {!isAdmin && {t('status.readonly_label')}} + {isDark ? t('status.dark') : t('status.light')}
); diff --git a/src/components/path-list/MergePreview.tsx b/src/components/path-list/MergePreview.tsx index c1895c7..f576f0c 100644 --- a/src/components/path-list/MergePreview.tsx +++ b/src/components/path-list/MergePreview.tsx @@ -1,22 +1,22 @@ import { useMemo } from 'react'; import { useAppStore } from '@/store/app-store'; +import { useTranslation } from 'react-i18next'; export function MergePreview() { - const dataVersion = useAppStore((s) => s.dataVersion); - void dataVersion; // 订阅版本号强制重渲染 const sysPaths = useAppStore((s) => s.sysPaths); const userPaths = useAppStore((s) => s.userPaths); const searchQuery = useAppStore((s) => s.searchQuery); + const { t } = useTranslation(); const allPaths = useMemo(() => { - const result: { path: string; source: '系统' | '用户'; index: number }[] = []; - sysPaths.all.forEach((p, i) => result.push({ path: p, source: '系统' as const, index: i })); - userPaths.all.forEach((p, i) => result.push({ path: p, source: '用户' as const, index: i })); + const result: { path: string; source: string; index: number }[] = []; + sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i })); + userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i })); if (!searchQuery) return result; const q = searchQuery.toLowerCase(); return result.filter((r) => r.path.toLowerCase().includes(q)); - }, [sysPaths, userPaths, searchQuery]); + }, [sysPaths, userPaths, searchQuery, t]); return (
@@ -27,8 +27,8 @@ export function MergePreview() { style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }} > # - 路径 - 来源 + {t('dialog.pathLabel')} + {t('merge.source')} diff --git a/src/components/path-list/PathTable.tsx b/src/components/path-list/PathTable.tsx index 99cf00f..b4a7930 100644 --- a/src/components/path-list/PathTable.tsx +++ b/src/components/path-list/PathTable.tsx @@ -12,8 +12,6 @@ interface PathRow { } export function PathTable({ tabId }: PathTableProps) { - const dataVersion = useAppStore((s) => s.dataVersion); - void dataVersion; // 订阅版本号强制重渲染 const sysPaths = useAppStore((s) => s.sysPaths); const userPaths = useAppStore((s) => s.userPaths); const searchQuery = useAppStore((s) => s.searchQuery); @@ -31,11 +29,11 @@ export function PathTable({ tabId }: PathTableProps) { // 过滤搜索 const filtered = useMemo(() => { - if (!searchQuery) return paths.all.map((p, i) => ({ path: p, index: i })); + if (!searchQuery) return paths.map((p, i) => ({ path: p, index: i })); const q = searchQuery.toLowerCase(); const result: PathRow[] = []; for (let i = 0; i < paths.length; i++) { - const p = paths.get(i)!; + const p = paths[i]; if (p.toLowerCase().includes(q)) result.push({ path: p, index: i }); } return result; @@ -44,7 +42,7 @@ export function PathTable({ tabId }: PathTableProps) { // 异步验证未缓存的路径 useEffect(() => { let cancelled = false; - const allPaths = paths.all; + const allPaths = paths; // 找出未缓存的路径 const toValidate = allPaths.filter((p) => !validationCache.has(p)); @@ -81,7 +79,7 @@ export function PathTable({ tabId }: PathTableProps) { // 异步展开环境变量(用于 tooltip) useEffect(() => { let cancelled = false; - const toExpand = paths.all.filter( + const toExpand = paths.filter( (p) => p.includes('%') && !expandedCache.has(p), ); if (toExpand.length === 0) return; @@ -146,7 +144,7 @@ export function PathTable({ tabId }: PathTableProps) { if (!isActive) return; window.dispatchEvent( new CustomEvent('path-dblclick', { - detail: { index: realIndex, path: paths.get(realIndex) }, + detail: { index: realIndex, path: paths[realIndex] }, }), ); }, diff --git a/src/components/toolbar/UndoRedoButtons.tsx b/src/components/toolbar/UndoRedoButtons.tsx index d0c13b6..dcb007b 100644 --- a/src/components/toolbar/UndoRedoButtons.tsx +++ b/src/components/toolbar/UndoRedoButtons.tsx @@ -16,9 +16,6 @@ export function UndoRedoButtons() { borderColor: 'var(--app-border)', }; - // 订阅状态更新(canUndo/canRedo 不会触发 re-render,用 setTimeout 简单轮询不优雅,但 Zustand 的 subscribe 可以) - // 这里简化为每次渲染时检查(因为 undo/redo 会修改列表触发重渲染) - return (