From bfd114d80fa5621c0f07b830688755710ea2e1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Tue, 26 May 2026 00:26:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=B4=A8=E9=87=8F=E6=8F=90=E5=8D=87=20=E2=80=94=20Str?= =?UTF-8?q?ingList=E2=86=92string[],=20strict=20=E6=A8=A1=E5=BC=8F,=20?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构重构: - StringList 类替换为不可变 string[](消除 dataVersion hack,Zustand 自然检测变化) - UndoRedoManager.undo/redo 返回新数组而非原地修改 - 删除 dataVersion 字段和 _bumpVersion() - 启用 TypeScript strict 模式 死代码清理: - 删除 string-list.ts, string-list.test.ts, use-path-validation.ts - Rust AppError 保留供未来使用 功能修复: - importFromJson 添加 try/catch - handleClean 使用真实格式验证替代 () => true - savePaths 保存前调用 backup_registry,处理部分保存失败 - importFromJson 校验非 object 类型输入 i18n 完善: - MergePreview/StatusBar 硬编码中文 → t() 调用 - 新增 merge.* 和 status.* 翻译键 Rust 改进: - registry.rs 抽取 load_paths/save_paths 通用函数,消除重复 - registry 新增 6 个单元测试(split/join/roundtrip) - backup.rs 时间戳加毫秒防覆盖,回退路径改为 home_dir 元数据: - package.json 名称→patheditor, 版本→4.0.0 - 新增 CHANGELOG.md - 移除 UndoRedoButtons 废弃注释 - tsconfig 添加 strict:true Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 38 +++ package.json | 4 +- src-tauri/src/commands/backup.rs | 5 +- src-tauri/src/commands/registry.rs | 127 ++++++---- src/components/layout/AppShell.tsx | 10 +- src/components/layout/StatusBar.tsx | 10 +- src/components/path-list/MergePreview.tsx | 16 +- src/components/path-list/PathTable.tsx | 12 +- src/components/toolbar/UndoRedoButtons.tsx | 3 - src/core/import-export.ts | 9 +- src/core/path-manager.ts | 73 ++---- src/core/string-list.ts | 84 ------- src/core/undo-redo.ts | 124 +++------- src/hooks/use-path-validation.ts | 35 --- src/i18n/locales/en.json | 10 + src/i18n/locales/zh-CN.json | 12 +- src/store/app-store.ts | 270 ++++++++------------- tests/unit/path-manager.test.ts | 93 +------ tests/unit/string-list.test.ts | 91 ------- tests/unit/undo-redo.test.ts | 217 +++++------------ tsconfig.app.json | 3 + 21 files changed, 410 insertions(+), 836 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 src/core/string-list.ts delete mode 100644 src/hooks/use-path-validation.ts delete mode 100644 tests/unit/string-list.test.ts 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 (