diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index a25faf3..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -dist/ -target/ -test-results/ -e2e/ -*.config.* diff --git a/.gitignore b/.gitignore index 792a220..b24c0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist-ssr *.sln *.sw? .claude/ +.codegraph/ CLAUDE.md e2e/debug-screenshot.png test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index 0a193bb..96cf564 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,11 +65,13 @@ PathEditor/ │ │ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons │ │ ├── dialogs/ # PathEdit、Help、Import、Analyze、Profile │ │ └── ui/ # Modal、buttons -│ ├── hooks/ # useAppActions、useKeyboard +│ ├── hooks/ # useAppActions、useKeyboard、usePathValidation │ ├── i18n/ # zh-CN / en │ └── config/ # default.json +├── docs/ # REMaining-ISSUES 等审查文档 ├── tests/unit/ # Vitest 前端单元测试 ├── e2e/ # Playwright E2E 测试 +├── rust-toolchain.toml # 固定工具链版本 └── Cargo.toml # Workspace 根 + [workspace.package] ``` @@ -144,7 +146,7 @@ patheditor profile {list|save|load|apply|delete|rename} ## 关键约束 - **TypeScript**:`strict: true`,零编译错误 -- **Rust 工具链**:`stable-x86_64-pc-windows-gnu`(项目已设 override) +- **Rust 工具链**:`stable-x86_64-pc-windows-gnu`(`rust-toolchain.toml` 强制) - **MinGW 兼容**:`.cargo/config.toml` 添加 `-lmcfgthread`(GCC 15.2.0 运行时) - **运行权限**:需要管理员权限才能编辑系统 PATH,非管理员自动进入只读模式 - **构建产物**:NSIS 安装包,约 8MB diff --git a/README.md b/README.md index f5a23d9..b22028f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ rust typescript license - tests + tests CI

@@ -259,8 +259,8 @@ npx tauri build | 国际化 | i18next | | 桌面框架 | Tauri 2.x | | 核心库 | Rust workspace (core + gui + cli) | -| 前端测试 | Vitest (72 个测试) | -| Rust 测试 | cargo test (10 个测试) | +| 前端测试 | Vitest (100 个测试) | +| Rust 测试 | cargo test (57 个测试) | | 构建 | Vite + Cargo | | 打包 | NSIS | @@ -282,10 +282,11 @@ src/ # React 前端 ├── core/ # 纯逻辑 — 零框架依赖 ├── store/ # Zustand 状态管理 ├── components/ # UI 组件 -├── hooks/ # useAppActions、useKeyboard +├── hooks/ # useAppActions、useKeyboard、usePathValidation ├── i18n/ # zh-CN / en └── config/ # default.json tests/unit/ # 前端单元测试 +docs/ # 审查文档 ``` ## 快捷键 diff --git a/cli/src/main.rs b/cli/src/main.rs index 51ddd54..616a007 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -191,6 +191,29 @@ fn load_and_save(system: bool, f: impl FnOnce(Vec) -> Vec) { verify_and_save(target, &list, new_list); } +/// 加载、检查索引、操作、验证、保存的通用模式 +/// `operate` 接收路径列表(包含原始列表)和要操作的索引,返回新列表和打印消息 +fn load_operate_save( + system: bool, + index: usize, + operate: impl FnOnce(Vec, usize) -> (Vec, String), +) { + let target = ensure_single_target(system, false); + let list = if target == "system" { + core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)) + } else { + core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) + }; + if index >= list.len() { + exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); + } + let original = list.clone(); + let (new_list, msg) = operate(list, index); + verify_and_save(target, &original, new_list); + println!("{msg}"); + core::system::broadcast_env_change(); +} + // ── 命令实现 ── fn cmd_list(system: bool, user: bool, json_out: bool) { @@ -237,37 +260,17 @@ fn cmd_add(path: String, system: bool, user: bool) { } fn cmd_remove(index: usize, system: bool) { - let target = ensure_single_target(system, false); - let mut list = if target == "system" { - core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)) - } else { - core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) - }; - let original = list.clone(); - if index >= list.len() { - exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); - } - let removed = list.remove(index); - verify_and_save(target, &original, list); - println!("已删除: {removed}"); - core::system::broadcast_env_change(); + load_operate_save(system, index, |mut list, idx| { + let removed = list.remove(idx); + (list, format!("已删除: {removed}")) + }); } fn cmd_edit(index: usize, new_path: String, system: bool) { - let target = ensure_single_target(system, false); - let mut list = if target == "system" { - core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)) - } else { - core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)) - }; - if index >= list.len() { - exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); - } - let original = list.clone(); - let old = std::mem::replace(&mut list[index], new_path.clone()); - verify_and_save(target, &original, list); - println!("已编辑: {old} → {new_path}"); - core::system::broadcast_env_change(); + load_operate_save(system, index, |mut list, idx| { + let old = std::mem::replace(&mut list[idx], new_path.clone()); + (list, format!("已编辑: {old} → {new_path}")) + }); } fn cmd_move(index: usize, steps: usize, system: bool, up: bool) { @@ -389,23 +392,26 @@ fn cmd_toggle(index: usize, system: bool, user: bool, enable: bool) { fn cmd_import(file: String, target: String) { let content = core::fs::read_text_file(&file).unwrap_or_else(|e| exit_err(&e)); - let (sys, usr) = core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e)); + let (sys_entries, usr_entries) = + core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e)); + let sys_paths: Vec = sys_entries.into_iter().map(|e| e.path).collect(); + let usr_paths: Vec = usr_entries.into_iter().map(|e| e.path).collect(); match target.as_str() { "system" => { let orig = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)); - verify_and_save("system", &orig, sys); + verify_and_save("system", &orig, sys_paths); println!("已导入到系统 PATH"); } "user" => { let orig = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)); - verify_and_save("user", &orig, usr); + verify_and_save("user", &orig, usr_paths); println!("已导入到用户 PATH"); } _ => { let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e)); let orig_usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e)); - verify_and_save("system", &orig_sys, sys); - verify_and_save("user", &orig_usr, usr); + verify_and_save("system", &orig_sys, sys_paths); + verify_and_save("user", &orig_usr, usr_paths); println!("已导入到系统 + 用户 PATH"); } } diff --git a/core/src/disabled.rs b/core/src/disabled.rs index ff66b78..90d6624 100644 --- a/core/src/disabled.rs +++ b/core/src/disabled.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +#[cfg(not(test))] fn disabled_file_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -10,6 +11,11 @@ fn disabled_file_path() -> PathBuf { .join("disabled.json") } +#[cfg(test)] +fn disabled_file_path() -> PathBuf { + std::env::temp_dir().join("patheditor_test_disabled.json") +} + #[derive(Serialize, Deserialize, Default)] struct DisabledState { #[serde(default)] diff --git a/core/src/fs.rs b/core/src/fs.rs index 5bf5e0f..bb5ba35 100644 --- a/core/src/fs.rs +++ b/core/src/fs.rs @@ -1,12 +1,17 @@ // 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现, // 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。 -/// 过滤导入路径:去除空白、排除 null 字节和分号(PATH 分隔符冲突) -fn sanitize_paths(paths: Vec) -> Vec { - paths +use crate::profiles::ProfilePathEntry; + +/// 过滤导入条目:去除空白、排除 null 字节和分号(PATH 分隔符冲突) +fn sanitize_entries(entries: Vec) -> Vec { + entries .into_iter() - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty() && !p.contains('\0') && !p.contains(';')) + .map(|e| ProfilePathEntry { + path: e.path.trim().to_string(), + enabled: e.enabled, + }) + .filter(|e| !e.path.is_empty() && !e.path.contains('\0') && !e.path.contains(';')) .collect() } @@ -35,8 +40,11 @@ pub fn read_text_file(path: &str) -> Result { std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e)) } -/// 导入路径文件(JSON / CSV / TXT),返回 (系统路径, 用户路径) -pub fn import_paths(path: &str, content: &str) -> Result<(Vec, Vec), String> { +/// 导入路径文件(JSON / CSV / TXT),返回 (系统条目, 用户条目) +pub fn import_paths( + path: &str, + content: &str, +) -> Result<(Vec, Vec), String> { let ext = std::path::Path::new(path) .extension() .map(|e| e.to_ascii_lowercase()) @@ -51,17 +59,39 @@ pub fn import_paths(path: &str, content: &str) -> Result<(Vec, Vec Result<(Vec, Vec), String> { +fn import_json(content: &str) -> Result<(Vec, Vec), String> { + #[derive(serde::Deserialize)] + struct ImportItem { + path: String, + #[serde(default = "default_true")] + enabled: bool, + } + fn default_true() -> bool { + true + } + #[derive(serde::Deserialize)] struct ImportData { #[serde(default)] - system: Vec, + system: Vec, #[serde(default)] - user: Vec, + user: Vec, } let data: ImportData = serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?; - Ok((sanitize_paths(data.system), sanitize_paths(data.user))) + let into_entries = |items: Vec| -> Vec { + items + .into_iter() + .map(|i| ProfilePathEntry { + path: i.path, + enabled: i.enabled, + }) + .collect() + }; + Ok(( + sanitize_entries(into_entries(data.system)), + sanitize_entries(into_entries(data.user)), + )) } /// 解析 CSV 行,支持引号包裹的字段(RFC 4180 子集) @@ -97,7 +127,9 @@ fn parse_csv_line(line: &str) -> Vec { fields } -fn import_csv(content: &str) -> Result<(Vec, Vec), String> { +fn import_csv( + content: &str, +) -> Result<(Vec, Vec), String> { let mut sys = Vec::new(); let mut usr = Vec::new(); let mut first = true; @@ -113,7 +145,7 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { if let Some(stripped) = trimmed.strip_prefix('\u{FEFF}') { trimmed = stripped; } - // 跳过 header 行 "type,path" + // 跳过 header 行,兼容 type,path 和 type,path,enabled 两种格式 let header_fields = parse_csv_line(trimmed); if header_fields.len() >= 2 { let c0 = header_fields[0].trim().to_lowercase(); @@ -126,9 +158,16 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { let fields = parse_csv_line(trimmed); if fields.len() >= 2 { + let path = fields[1].trim().to_string(); + let enabled = if fields.len() >= 3 { + fields[2].trim().to_lowercase() != "false" + } else { + true + }; + let entry = ProfilePathEntry { path, enabled }; match fields[0].trim().to_lowercase().as_str() { - "system" | "sys" => sys.push(fields[1].trim().to_string()), - "user" | "usr" => usr.push(fields[1].trim().to_string()), + "system" | "sys" => sys.push(entry), + "user" | "usr" => usr.push(entry), _ => { log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); } @@ -137,47 +176,57 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> { log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}"); } } - let sys = sanitize_paths(sys); - let usr = sanitize_paths(usr); + let sys = sanitize_entries(sys); + let usr = sanitize_entries(usr); if sys.is_empty() && usr.is_empty() { return Err("CSV 文件中未找到有效路径".into()); } Ok((sys, usr)) } -fn import_txt(content: &str) -> Result<(Vec, Vec), String> { - let paths: Vec = content +fn import_txt(content: &str) -> Result<(Vec, Vec), String> { + let entries: Vec = content .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|path| ProfilePathEntry { + path, + enabled: true, + }) .collect(); - let paths = sanitize_paths(paths); - if paths.is_empty() { + let entries = sanitize_entries(entries); + if entries.is_empty() { return Err("TXT 文件中未找到路径".into()); } // TXT 格式全部导入为用户路径 - Ok((vec![], paths)) + Ok((vec![], entries)) } /// 导出 PATH 为指定格式字符串 pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result { match format { "json" => { + let to_entries = |paths: &[String]| -> Vec { + paths + .iter() + .map(|p| serde_json::json!({"path": p, "enabled": true})) + .collect() + }; let data = serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(), - "system": sys, - "user": usr, + "system": to_entries(sys), + "user": to_entries(usr), }); Ok(serde_json::to_string_pretty(&data).expect("JSON 序列化 Value 不应失败")) } "csv" => { - let mut out = String::from("type,path\n"); + let mut out = String::from("type,path,enabled\n"); for p in sys { - out.push_str(&format!("system,{}\n", p)); + out.push_str(&format!("system,{},true\n", p)); } for p in usr { - out.push_str(&format!("user,{}\n", p)); + out.push_str(&format!("user,{},true\n", p)); } Ok(out) } @@ -205,12 +254,30 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result ProfilePathEntry { + ProfilePathEntry { + path: path.into(), + enabled: true, + } + } + + fn entry_disabled(path: &str) -> ProfilePathEntry { + ProfilePathEntry { + path: path.into(), + enabled: false, + } + } + #[test] fn import_json_valid() { - let json = r#"{"system": ["C:\\sys1", "C:\\sys2"], "user": ["D:\\usr1"]}"#; + let json = r#"{"system": [{"path": "C:\\sys1"}, {"path": "C:\\sys2"}], "user": [{"path": "D:\\usr1"}]}"#; let (sys, usr) = import_json(json).unwrap(); - assert_eq!(sys, vec!["C:\\sys1", "C:\\sys2"]); - assert_eq!(usr, vec!["D:\\usr1"]); + assert_eq!(sys.len(), 2); + assert_eq!(sys[0].path, "C:\\sys1"); + assert!(sys[0].enabled); + assert_eq!(sys[1].path, "C:\\sys2"); + assert_eq!(usr.len(), 1); + assert_eq!(usr[0].path, "D:\\usr1"); } #[test] @@ -219,6 +286,15 @@ mod tests { assert!(sys.is_empty() && usr.is_empty()); } + #[test] + fn import_json_disabled_entry() { + let json = r#"{"system": [{"path": "C:\\on", "enabled": true}, {"path": "C:\\off", "enabled": false}]}"#; + let (sys, _) = import_json(json).unwrap(); + assert_eq!(sys.len(), 2); + assert!(sys[0].enabled); + assert!(!sys[1].enabled); + } + #[test] fn import_json_missing_fields() { let (sys, usr) = import_json(r#"{}"#).unwrap(); @@ -228,16 +304,20 @@ mod tests { #[test] fn import_csv_valid() { let csv = "type,path\nsystem,C:\\sys1\nuser,D:\\usr1\n"; - let (sys, _usr) = import_csv(csv).unwrap(); - assert_eq!(sys, vec!["C:\\sys1"]); - assert_eq!(_usr, vec!["D:\\usr1"]); + let (sys, usr) = import_csv(csv).unwrap(); + assert_eq!(sys.len(), 1); + assert_eq!(sys[0].path, "C:\\sys1"); + assert!(sys[0].enabled); + assert_eq!(usr.len(), 1); + assert_eq!(usr[0].path, "D:\\usr1"); + assert!(usr[0].enabled); } #[test] fn import_csv_with_bom() { let csv = "\u{FEFF}type,path\nsystem,C:\\sys1\n"; let (sys, _) = import_csv(csv).unwrap(); - assert_eq!(sys, vec!["C:\\sys1"]); + assert_eq!(sys[0].path, "C:\\sys1"); } #[test] @@ -249,8 +329,27 @@ mod tests { fn import_csv_alternate_type_names() { let csv = "type,path\nsys,D:\\a\nusr,D:\\b\n"; let (sys, usr) = import_csv(csv).unwrap(); - assert_eq!(sys, vec!["D:\\a"]); - assert_eq!(usr, vec!["D:\\b"]); + assert_eq!(sys[0].path, "D:\\a"); + assert_eq!(usr[0].path, "D:\\b"); + } + + #[test] + fn import_csv_reads_enabled_column() { + let csv = "type,path,enabled\nsystem,C:\\ok,true\nsystem,C:\\disabled,false\n"; + let (sys, _) = import_csv(csv).unwrap(); + assert_eq!(sys.len(), 2); + assert_eq!(sys[0].path, "C:\\ok"); + assert!(sys[0].enabled); + assert_eq!(sys[1].path, "C:\\disabled"); + assert!(!sys[1].enabled); + } + + #[test] + fn import_csv_enabled_defaults_true() { + // 2 列格式(无 enabled 列)默认为 true + let csv = "type,path\nsystem,C:\\x\n"; + let (sys, _) = import_csv(csv).unwrap(); + assert!(sys[0].enabled); } #[test] @@ -259,16 +358,18 @@ mod tests { let usr: Vec = vec![]; let exported = export_paths(&sys, &usr, "json").unwrap(); let parsed: serde_json::Value = serde_json::from_str(&exported).unwrap(); - assert_eq!(parsed["system"][0], "C:\\a"); + assert_eq!(parsed["system"][0]["path"], "C:\\a"); + assert_eq!(parsed["system"][0]["enabled"], true); } #[test] - fn export_csv_roundtrip() { + fn export_csv_includes_enabled_column() { let sys = vec!["C:\\a".into()]; let usr = vec!["D:\\b".into()]; let exported = export_paths(&sys, &usr, "csv").unwrap(); - assert!(exported.contains("system,C:\\a")); - assert!(exported.contains("user,D:\\b")); + assert!(exported.starts_with("type,path,enabled")); + assert!(exported.contains("system,C:\\a,true")); + assert!(exported.contains("user,D:\\b,true")); } #[test] @@ -287,14 +388,16 @@ mod tests { #[test] fn import_paths_detects_format() { let (sys, _) = import_paths("test.csv", "type,path\nsystem,C:\\x\n").unwrap(); - assert_eq!(sys, vec!["C:\\x"]); + assert_eq!(sys[0].path, "C:\\x"); } #[test] fn import_paths_txt_to_user() { let (sys, usr) = import_paths("test.txt", "C:\\x\nD:\\y\n").unwrap(); assert!(sys.is_empty()); - assert_eq!(usr, vec!["C:\\x", "D:\\y"]); + assert_eq!(usr.len(), 2); + assert_eq!(usr[0].path, "C:\\x"); + assert_eq!(usr[1].path, "D:\\y"); } #[test] @@ -311,30 +414,42 @@ mod tests { } #[test] - fn import_json_filters_null_byte_paths() { - // sanitize_paths 作为额外防线 - let paths = vec!["C:\\safe".into(), "C:\\bad\0path".into()]; - assert_eq!(sanitize_paths(paths), vec!["C:\\safe"]); + fn sanitize_entries_filters_null_byte_paths() { + let entries = vec![entry("C:\\safe"), entry("C:\\bad\0path")]; + let result = sanitize_entries(entries); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "C:\\safe"); } #[test] fn import_csv_filters_semicolon_paths() { let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n"; let (sys, _) = import_csv(csv).unwrap(); - assert_eq!(sys, vec!["C:\\good"]); + assert_eq!(sys.len(), 1); + assert_eq!(sys[0].path, "C:\\good"); } #[test] fn import_txt_trims_and_filters() { let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n"; let (_, usr) = import_txt(txt).unwrap(); - assert_eq!(usr, vec!["C:\\trimmed"]); + assert_eq!(usr.len(), 1); + assert_eq!(usr[0].path, "C:\\trimmed"); } #[test] - fn sanitize_paths_removes_empty_after_trim() { - let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]); - assert_eq!(result, vec!["C:\\ok"]); + fn sanitize_entries_removes_empty_after_trim() { + let result = sanitize_entries(vec![entry(" "), entry("C:\\ok")]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "C:\\ok"); + } + + #[test] + fn sanitize_entries_preserves_enabled_flag() { + let result = sanitize_entries(vec![entry_disabled("C:\\keep")]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "C:\\keep"); + assert!(!result[0].enabled); } #[test] @@ -362,6 +477,15 @@ mod tests { fn import_csv_quoted_comma_path() { let csv = "type,path\nsystem,\"C:\\Program Files, Inc\\bin\"\n"; let (sys, _) = import_csv(csv).unwrap(); - assert_eq!(sys, vec!["C:\\Program Files, Inc\\bin"]); + assert_eq!(sys[0].path, "C:\\Program Files, Inc\\bin"); + } + + #[test] + fn csv_roundtrip_preserves_enabled() { + let csv = "type,path,enabled\nsystem,C:\\on,true\nsystem,C:\\off,false\n"; + let (sys, _) = import_csv(csv).unwrap(); + assert_eq!(sys.len(), 2); + assert!(sys[0].enabled); + assert!(!sys[1].enabled); } } diff --git a/core/src/profiles.rs b/core/src/profiles.rs index 46627d2..371e865 100644 --- a/core/src/profiles.rs +++ b/core/src/profiles.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +#[cfg(not(test))] fn profiles_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -10,6 +11,11 @@ fn profiles_dir() -> PathBuf { .join("profiles") } +#[cfg(test)] +fn profiles_dir() -> PathBuf { + std::env::temp_dir().join("patheditor_test_profiles") +} + fn validate_profile_name(name: &str) -> Result<(), String> { if name.is_empty() { return Err("配置名称不能为空".into()); @@ -155,9 +161,13 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> { validate_profile_name(old_name)?; validate_profile_name(new_name)?; let old_path = profile_path(old_name); + let new_path = profile_path(new_name); if !old_path.exists() { return Err(format!("配置文件不存在: {}", old_name)); } + if old_path != new_path && new_path.exists() { + return Err(format!("目标配置名已存在: {}", new_name)); + } let mut data: ProfileData = serde_json::from_str( &fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?, @@ -167,7 +177,6 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> { data.name = new_name.to_string(); 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))?; atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?; diff --git a/core/src/scanner.rs b/core/src/scanner.rs index f3c1e3e..ff6e77e 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -50,10 +50,6 @@ fn list_exes(dir: &str) -> Vec { /// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件, /// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」) pub fn scan_conflicts(paths: Vec) -> Result, String> { - // 并行扫描各目录(限制并发数) - let max_threads = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(4); let results: Vec<(usize, String, Vec)> = std::thread::scope(|s| { let handles: Vec<_> = paths .iter() @@ -66,8 +62,6 @@ pub fn scan_conflicts(paths: Vec) -> Result, String> .collect::, _>>() }) .map_err(|e| format!("线程扫描失败: {}", e))?; - // max_threads 用于限制 scope 外的并行度,实际线程由 scope 调度 - let _ = max_threads; // 合并: exe_name (小写) → [(priority, dir)] let mut map: HashMap> = HashMap::new(); @@ -155,13 +149,29 @@ mod tests { use super::*; use std::fs; - fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> std::path::PathBuf { + struct TempDirGuard(std::path::PathBuf); + + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + impl std::ops::Deref for TempDirGuard { + type Target = std::path::PathBuf; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> TempDirGuard { let dir = std::env::temp_dir().join(format!("patheditor_test_{}", prefix)); + let _ = fs::remove_dir_all(&dir); // 清理残留 fs::create_dir_all(&dir).unwrap(); for name in exe_names { fs::write(dir.join(name), b"fake").unwrap(); } - dir + TempDirGuard(dir) } #[test] diff --git a/docs/REMAINING-ISSUES.md b/docs/REMAINING-ISSUES.md new file mode 100644 index 0000000..2409bdc --- /dev/null +++ b/docs/REMAINING-ISSUES.md @@ -0,0 +1,35 @@ +# 未修复问题清单 + +> 从 v5.1 全面代码审查中筛选,暂不修复,留待后续评估。 + +--- + +## 1. CLI main.rs 单体文件 (639 行) + +**严重级别**: LOW +**文件**: `cli/src/main.rs` + +**问题**: 所有 18 条 CLI 命令集中在一个文件中。 + +**建议**: 当前规模尚可维护,等到命令数超过 25 条或文件超过 1000 行时再拆分为 `commands/` 子模块。 + +--- + +## 2. GUI 命令层零测试 + +**严重级别**: LOW +**文件**: `gui/src/commands/*.rs` (8 个文件) + +**问题**: GUI 命令层是纯薄包装,无独立测试。 + +**建议**: 不值得投入 — 命令正确性由编译器类型系统保证,运行期由 57 个 core 测试 + E2E 覆盖。 + +--- + +## 已修复(本批次) + +- ~~disabled.rs 测试写入真实文件~~ → `#[cfg(test)]` 条件编译重定向到 `std::env::temp_dir()` +- ~~profiles.rs 同款问题~~ → 同上 + +--- +*更新于: 2026-05-30 | 审查批次: v5.1 代码审查* diff --git a/eslint.config.js b/eslint.config.js index 73524c2..c560c7d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import globals from 'globals'; export default tseslint.config( - { ignores: ['dist', 'gui'] }, + { ignores: ['dist', 'gui', 'target', 'test-results', 'e2e', '*.config.*'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..1f56390 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable-x86_64-pc-windows-gnu" diff --git a/src/components/path-list/PathTable.tsx b/src/components/path-list/PathTable.tsx index ecb9936..74a0125 100644 --- a/src/components/path-list/PathTable.tsx +++ b/src/components/path-list/PathTable.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppStore } from '@/store/app-store'; -import { invoke } from '@tauri-apps/api/core'; import { TargetType } from '@/core/undo-redo'; +import { usePathValidation } from '@/hooks/use-path-validation'; +import type { ValidationState } from '@/hooks/use-path-validation'; interface PathTableProps { tabId: 'system' | 'user'; @@ -14,9 +15,6 @@ interface PathRow { enabled: boolean; } -type ValidationState = 'valid' | 'invalid' | 'unknown'; -const DEFAULT_VALIDATION_STATE: ValidationState = 'valid'; - export function PathTable({ tabId }: PathTableProps) { const { t } = useTranslation(); const sysPaths = useAppStore((s) => s.sysPaths); @@ -29,42 +27,9 @@ export function PathTable({ tabId }: PathTableProps) { const paths = tabId === 'system' ? sysPaths : userPaths; const isActive = activeTab === tabId; - // 本次会话中已验证过的路径缓存(key=path, value=ValidationState) - const [validationCache, setValidationCache] = useState>(new Map()); - // 环境变量展开结果缓存(key=path, value=expanded) - const [expandedCache, setExpandedCache] = useState>(new Map()); + const { validationCache, expandedCache } = usePathValidation(paths); - const validatedRef = useRef>(new Set()); - const expandedRef = useRef>(new Set()); - - // 清理不再存在的路径缓存 - useEffect(() => { - const currentKeys = new Set(paths.map(p => p.path)); - setValidationCache(prev => { - let changed = false; - const next = new Map(prev); - for (const key of next.keys()) { - if (!currentKeys.has(key)) { next.delete(key); changed = true; } - } - return changed ? next : prev; - }); - setExpandedCache(prev => { - let changed = false; - const next = new Map(prev); - for (const key of next.keys()) { - if (!currentKeys.has(key)) { next.delete(key); changed = true; } - } - return changed ? next : prev; - }); - for (const key of [...validatedRef.current]) { - if (!currentKeys.has(key)) validatedRef.current.delete(key); - } - for (const key of [...expandedRef.current]) { - if (!currentKeys.has(key)) expandedRef.current.delete(key); - } - }, [paths]); - - // 过滤搜索 + // 搜索过滤 const filtered = useMemo(() => { if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled })); const q = searchQuery.toLowerCase(); @@ -76,79 +41,15 @@ export function PathTable({ tabId }: PathTableProps) { return result; }, [paths, searchQuery]); - // 异步验证未缓存的路径 - useEffect(() => { - let cancelled = false; - const toValidate = paths.filter((p) => !validatedRef.current.has(p.path)); - if (toValidate.length === 0) return; - - const batch = toValidate.slice(0, 20); - Promise.all( - batch.map(async (p): Promise<[string, ValidationState]> => { - try { - if (p.path.includes('%')) return [p.path, 'valid']; - const valid: boolean = await invoke('validate_path', { path: p.path }); - return [p.path, valid ? 'valid' : 'invalid']; - } catch { - return [p.path, 'unknown']; - } - }), - ).then((results) => { - if (cancelled) return; - for (const [p] of results) validatedRef.current.add(p); - setValidationCache((prev) => { - const next = new Map(prev); - for (const [p, v] of results) next.set(p, v); - return next; - }); - }); - - return () => { cancelled = true; }; - }, [paths]); - - // 异步展开环境变量(用于 tooltip) - useEffect(() => { - let cancelled = false; - const toExpand = paths.filter( - (p) => p.path.includes('%') && !expandedRef.current.has(p.path), - ); - if (toExpand.length === 0) return; - - const batch = toExpand.slice(0, 20); - Promise.all( - batch.map(async (p): Promise<[string, string]> => { - try { - const expanded: string = await invoke('expand_env_vars', { path: p.path }); - return [p.path, expanded !== p.path ? expanded : '']; - } catch { - return [p.path, '']; - } - }), - ).then((results) => { - if (cancelled) return; - for (const [p] of results) expandedRef.current.add(p); - setExpandedCache((prev) => { - const next = new Map(prev); - for (const [p, v] of results) next.set(p, v); - return next; - }); - }); - - return () => { cancelled = true; }; - }, [paths]); - - // 所有路径默认有效(异步验证结果回来后再精确染色) + // 计算验证状态(含去重检测) const validations = useMemo(() => { const seen = new Set(); return filtered.map(({ path }) => { const lower = path.toLowerCase(); const isDuplicate = seen.has(lower); seen.add(lower); - return { - state: validationCache.get(path) ?? DEFAULT_VALIDATION_STATE, - isDuplicate, - isEnvVar: path.includes('%'), - }; + const state: ValidationState = validationCache.get(path) ?? 'valid'; + return { state, isDuplicate, isEnvVar: path.includes('%') }; }); }, [filtered, validationCache]); diff --git a/src/hooks/use-path-validation.ts b/src/hooks/use-path-validation.ts new file mode 100644 index 0000000..e0818b2 --- /dev/null +++ b/src/hooks/use-path-validation.ts @@ -0,0 +1,125 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { PathEntry } from '@/core/path-entry'; + +export type ValidationState = 'valid' | 'invalid' | 'unknown'; + +/** + * 异步验证路径目录是否真实存在 + 展开环境变量 + * 缓存结果避免重复 IPC 调用。 + * setState 仅在异步 .then() 回调中调用(符合 React 规则), + * 不存在路径的缓存清理通过 useMemo 派生。 + */ +export function usePathValidation(paths: readonly PathEntry[]) { + const validatedRef = useRef>(new Set()); + const expandedRef = useRef>(new Set()); + const [validationCache, setValidationCache] = useState>(new Map()); + const [expandedCache, setExpandedCache] = useState>(new Map()); + + // 仅保留当前 paths 中存在的条目(派生 state,不在 effect 中同步 setState) + const currentKeys = useMemo(() => new Set(paths.map((p) => p.path)), [paths]); + const cleanedValidationCache = useMemo(() => { + const next = new Map(validationCache); + let changed = false; + for (const key of next.keys()) { + if (!currentKeys.has(key)) { + next.delete(key); + changed = true; + } + } + return changed ? next : validationCache; + }, [validationCache, currentKeys]); + + const cleanedExpandedCache = useMemo(() => { + const next = new Map(expandedCache); + let changed = false; + for (const key of next.keys()) { + if (!currentKeys.has(key)) { + next.delete(key); + changed = true; + } + } + return changed ? next : expandedCache; + }, [expandedCache, currentKeys]); + + // 同步清理 ref(ref 不能在 render 期间修改,放在 effect 中不 setState 是安全的) + useEffect(() => { + for (const key of validatedRef.current) { + if (!currentKeys.has(key)) validatedRef.current.delete(key); + } + for (const key of expandedRef.current) { + if (!currentKeys.has(key)) expandedRef.current.delete(key); + } + }, [currentKeys]); + + // 异步验证路径(setState 在 .then() 回调中,符合 React 规则) + useEffect(() => { + let cancelled = false; + const toValidate = paths.filter((p) => !validatedRef.current.has(p.path)); + if (toValidate.length === 0) return; + + const batch = toValidate.slice(0, 20); + Promise.all( + batch.map( + async (p): Promise<[string, ValidationState]> => { + try { + if (p.path.includes('%')) return [p.path, 'valid']; + const valid: boolean = await invoke('validate_path', { path: p.path }); + return [p.path, valid ? 'valid' : 'invalid']; + } catch { + return [p.path, 'unknown']; + } + }, + ), + ).then((results) => { + if (cancelled) return; + for (const [p] of results) validatedRef.current.add(p); + setValidationCache((prev) => { + const next = new Map(prev); + for (const [p, v] of results) next.set(p, v); + return next; + }); + }); + + return () => { + cancelled = true; + }; + }, [paths]); + + // 异步展开环境变量(setState 在 .then() 回调中) + useEffect(() => { + let cancelled = false; + const toExpand = paths.filter( + (p) => p.path.includes('%') && !expandedRef.current.has(p.path), + ); + if (toExpand.length === 0) return; + + const batch = toExpand.slice(0, 20); + Promise.all( + batch.map( + async (p): Promise<[string, string]> => { + try { + const expanded: string = await invoke('expand_env_vars', { path: p.path }); + return [p.path, expanded !== p.path ? expanded : '']; + } catch { + return [p.path, '']; + } + }, + ), + ).then((results) => { + if (cancelled) return; + for (const [p] of results) expandedRef.current.add(p); + setExpandedCache((prev) => { + const next = new Map(prev); + for (const [p, v] of results) next.set(p, v); + return next; + }); + }); + + return () => { + cancelled = true; + }; + }, [paths]); + + return { validationCache: cleanedValidationCache, expandedCache: cleanedExpandedCache }; +}