mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 09:55:56 +08:00
fix: v5.1 代码审查修复 — ESLint/CSV/测试隔离/CLI 去重
- ESLint: 迁移到 flat config ignores,删除已废弃的 .eslintignore
- CSV: Rust/TS 格式对齐,统一 type,path,enabled 3 列
- JSON: 导入导出统一为 {path, enabled} 对象格式
- scanner: 移除未使用的 max_threads 死代码 + TempDirGuard 测试清理
- profiles: rename_profile 添加目标存在检查
- CLI: 抽取 load_operate_save helper,简化 cmd_remove/cmd_edit
- PathTable: 抽取 usePathValidation hook,消除 set-state-in-effect
- 测试隔离: disabled/profiles 通过 #[cfg(test)] 重定向到 temp dir
- toolchain: 新增 rust-toolchain.toml 固定 stable-x86_64-pc-windows-gnu
- docs: 更新 CLAUDE.md/README.md 测试计数 + 架构树
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
target/
|
|
||||||
test-results/
|
|
||||||
e2e/
|
|
||||||
*.config.*
|
|
||||||
@@ -23,6 +23,7 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.claude/
|
.claude/
|
||||||
|
.codegraph/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
e2e/debug-screenshot.png
|
e2e/debug-screenshot.png
|
||||||
test-results/
|
test-results/
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ PathEditor/
|
|||||||
│ │ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons
|
│ │ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons
|
||||||
│ │ ├── dialogs/ # PathEdit、Help、Import、Analyze、Profile
|
│ │ ├── dialogs/ # PathEdit、Help、Import、Analyze、Profile
|
||||||
│ │ └── ui/ # Modal、buttons
|
│ │ └── ui/ # Modal、buttons
|
||||||
│ ├── hooks/ # useAppActions、useKeyboard
|
│ ├── hooks/ # useAppActions、useKeyboard、usePathValidation
|
||||||
│ ├── i18n/ # zh-CN / en
|
│ ├── i18n/ # zh-CN / en
|
||||||
│ └── config/ # default.json
|
│ └── config/ # default.json
|
||||||
|
├── docs/ # REMaining-ISSUES 等审查文档
|
||||||
├── tests/unit/ # Vitest 前端单元测试
|
├── tests/unit/ # Vitest 前端单元测试
|
||||||
├── e2e/ # Playwright E2E 测试
|
├── e2e/ # Playwright E2E 测试
|
||||||
|
├── rust-toolchain.toml # 固定工具链版本
|
||||||
└── Cargo.toml # Workspace 根 + [workspace.package]
|
└── Cargo.toml # Workspace 根 + [workspace.package]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,7 +146,7 @@ patheditor profile {list|save|load|apply|delete|rename}
|
|||||||
## 关键约束
|
## 关键约束
|
||||||
|
|
||||||
- **TypeScript**:`strict: true`,零编译错误
|
- **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 运行时)
|
- **MinGW 兼容**:`.cargo/config.toml` 添加 `-lmcfgthread`(GCC 15.2.0 运行时)
|
||||||
- **运行权限**:需要管理员权限才能编辑系统 PATH,非管理员自动进入只读模式
|
- **运行权限**:需要管理员权限才能编辑系统 PATH,非管理员自动进入只读模式
|
||||||
- **构建产物**:NSIS 安装包,约 8MB
|
- **构建产物**:NSIS 安装包,约 8MB
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<img src="https://img.shields.io/badge/rust-1.95-000000" alt="rust">
|
<img src="https://img.shields.io/badge/rust-1.95-000000" alt="rust">
|
||||||
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
|
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
||||||
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
|
<img src="https://img.shields.io/badge/tests-157%20passed-brightgreen" alt="tests">
|
||||||
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
|
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -259,8 +259,8 @@ npx tauri build
|
|||||||
| 国际化 | i18next |
|
| 国际化 | i18next |
|
||||||
| 桌面框架 | Tauri 2.x |
|
| 桌面框架 | Tauri 2.x |
|
||||||
| 核心库 | Rust workspace (core + gui + cli) |
|
| 核心库 | Rust workspace (core + gui + cli) |
|
||||||
| 前端测试 | Vitest (72 个测试) |
|
| 前端测试 | Vitest (100 个测试) |
|
||||||
| Rust 测试 | cargo test (10 个测试) |
|
| Rust 测试 | cargo test (57 个测试) |
|
||||||
| 构建 | Vite + Cargo |
|
| 构建 | Vite + Cargo |
|
||||||
| 打包 | NSIS |
|
| 打包 | NSIS |
|
||||||
|
|
||||||
@@ -282,10 +282,11 @@ src/ # React 前端
|
|||||||
├── core/ # 纯逻辑 — 零框架依赖
|
├── core/ # 纯逻辑 — 零框架依赖
|
||||||
├── store/ # Zustand 状态管理
|
├── store/ # Zustand 状态管理
|
||||||
├── components/ # UI 组件
|
├── components/ # UI 组件
|
||||||
├── hooks/ # useAppActions、useKeyboard
|
├── hooks/ # useAppActions、useKeyboard、usePathValidation
|
||||||
├── i18n/ # zh-CN / en
|
├── i18n/ # zh-CN / en
|
||||||
└── config/ # default.json
|
└── config/ # default.json
|
||||||
tests/unit/ # 前端单元测试
|
tests/unit/ # 前端单元测试
|
||||||
|
docs/ # 审查文档
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
|
|||||||
+39
-33
@@ -191,6 +191,29 @@ fn load_and_save(system: bool, f: impl FnOnce(Vec<String>) -> Vec<String>) {
|
|||||||
verify_and_save(target, &list, new_list);
|
verify_and_save(target, &list, new_list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载、检查索引、操作、验证、保存的通用模式
|
||||||
|
/// `operate` 接收路径列表(包含原始列表)和要操作的索引,返回新列表和打印消息
|
||||||
|
fn load_operate_save(
|
||||||
|
system: bool,
|
||||||
|
index: usize,
|
||||||
|
operate: impl FnOnce(Vec<String>, usize) -> (Vec<String>, 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) {
|
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) {
|
fn cmd_remove(index: usize, system: bool) {
|
||||||
let target = ensure_single_target(system, false);
|
load_operate_save(system, index, |mut list, idx| {
|
||||||
let mut list = if target == "system" {
|
let removed = list.remove(idx);
|
||||||
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
|
(list, format!("已删除: {removed}"))
|
||||||
} 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_edit(index: usize, new_path: String, system: bool) {
|
fn cmd_edit(index: usize, new_path: String, system: bool) {
|
||||||
let target = ensure_single_target(system, false);
|
load_operate_save(system, index, |mut list, idx| {
|
||||||
let mut list = if target == "system" {
|
let old = std::mem::replace(&mut list[idx], new_path.clone());
|
||||||
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
|
(list, format!("已编辑: {old} → {new_path}"))
|
||||||
} 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
|
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) {
|
fn cmd_import(file: String, target: String) {
|
||||||
let content = core::fs::read_text_file(&file).unwrap_or_else(|e| exit_err(&e));
|
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<String> = sys_entries.into_iter().map(|e| e.path).collect();
|
||||||
|
let usr_paths: Vec<String> = usr_entries.into_iter().map(|e| e.path).collect();
|
||||||
match target.as_str() {
|
match target.as_str() {
|
||||||
"system" => {
|
"system" => {
|
||||||
let orig = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
|
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");
|
println!("已导入到系统 PATH");
|
||||||
}
|
}
|
||||||
"user" => {
|
"user" => {
|
||||||
let orig = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
|
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");
|
println!("已导入到用户 PATH");
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
|
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));
|
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("system", &orig_sys, sys_paths);
|
||||||
verify_and_save("user", &orig_usr, usr);
|
verify_and_save("user", &orig_usr, usr_paths);
|
||||||
println!("已导入到系统 + 用户 PATH");
|
println!("已导入到系统 + 用户 PATH");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
fn disabled_file_path() -> PathBuf {
|
fn disabled_file_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
@@ -10,6 +11,11 @@ fn disabled_file_path() -> PathBuf {
|
|||||||
.join("disabled.json")
|
.join("disabled.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn disabled_file_path() -> PathBuf {
|
||||||
|
std::env::temp_dir().join("patheditor_test_disabled.json")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
struct DisabledState {
|
struct DisabledState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+176
-52
@@ -1,12 +1,17 @@
|
|||||||
// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现,
|
// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现,
|
||||||
// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
|
// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
|
||||||
|
|
||||||
/// 过滤导入路径:去除空白、排除 null 字节和分号(PATH 分隔符冲突)
|
use crate::profiles::ProfilePathEntry;
|
||||||
fn sanitize_paths(paths: Vec<String>) -> Vec<String> {
|
|
||||||
paths
|
/// 过滤导入条目:去除空白、排除 null 字节和分号(PATH 分隔符冲突)
|
||||||
|
fn sanitize_entries(entries: Vec<ProfilePathEntry>) -> Vec<ProfilePathEntry> {
|
||||||
|
entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| p.trim().to_string())
|
.map(|e| ProfilePathEntry {
|
||||||
.filter(|p| !p.is_empty() && !p.contains('\0') && !p.contains(';'))
|
path: e.path.trim().to_string(),
|
||||||
|
enabled: e.enabled,
|
||||||
|
})
|
||||||
|
.filter(|e| !e.path.is_empty() && !e.path.contains('\0') && !e.path.contains(';'))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +40,11 @@ pub fn read_text_file(path: &str) -> Result<String, String> {
|
|||||||
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
|
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入路径文件(JSON / CSV / TXT),返回 (系统路径, 用户路径)
|
/// 导入路径文件(JSON / CSV / TXT),返回 (系统条目, 用户条目)
|
||||||
pub fn import_paths(path: &str, content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
pub fn import_paths(
|
||||||
|
path: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(Vec<ProfilePathEntry>, Vec<ProfilePathEntry>), String> {
|
||||||
let ext = std::path::Path::new(path)
|
let ext = std::path::Path::new(path)
|
||||||
.extension()
|
.extension()
|
||||||
.map(|e| e.to_ascii_lowercase())
|
.map(|e| e.to_ascii_lowercase())
|
||||||
@@ -51,17 +59,39 @@ pub fn import_paths(path: &str, content: &str) -> Result<(Vec<String>, Vec<Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
fn import_json(content: &str) -> Result<(Vec<ProfilePathEntry>, Vec<ProfilePathEntry>), String> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ImportItem {
|
||||||
|
path: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ImportData {
|
struct ImportData {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
system: Vec<String>,
|
system: Vec<ImportItem>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
user: Vec<String>,
|
user: Vec<ImportItem>,
|
||||||
}
|
}
|
||||||
let data: ImportData =
|
let data: ImportData =
|
||||||
serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?;
|
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<ImportItem>| -> Vec<ProfilePathEntry> {
|
||||||
|
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 子集)
|
/// 解析 CSV 行,支持引号包裹的字段(RFC 4180 子集)
|
||||||
@@ -97,7 +127,9 @@ fn parse_csv_line(line: &str) -> Vec<String> {
|
|||||||
fields
|
fields
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
fn import_csv(
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(Vec<ProfilePathEntry>, Vec<ProfilePathEntry>), String> {
|
||||||
let mut sys = Vec::new();
|
let mut sys = Vec::new();
|
||||||
let mut usr = Vec::new();
|
let mut usr = Vec::new();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
@@ -113,7 +145,7 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
|||||||
if let Some(stripped) = trimmed.strip_prefix('\u{FEFF}') {
|
if let Some(stripped) = trimmed.strip_prefix('\u{FEFF}') {
|
||||||
trimmed = stripped;
|
trimmed = stripped;
|
||||||
}
|
}
|
||||||
// 跳过 header 行 "type,path"
|
// 跳过 header 行,兼容 type,path 和 type,path,enabled 两种格式
|
||||||
let header_fields = parse_csv_line(trimmed);
|
let header_fields = parse_csv_line(trimmed);
|
||||||
if header_fields.len() >= 2 {
|
if header_fields.len() >= 2 {
|
||||||
let c0 = header_fields[0].trim().to_lowercase();
|
let c0 = header_fields[0].trim().to_lowercase();
|
||||||
@@ -126,9 +158,16 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
|||||||
|
|
||||||
let fields = parse_csv_line(trimmed);
|
let fields = parse_csv_line(trimmed);
|
||||||
if fields.len() >= 2 {
|
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() {
|
match fields[0].trim().to_lowercase().as_str() {
|
||||||
"system" | "sys" => sys.push(fields[1].trim().to_string()),
|
"system" | "sys" => sys.push(entry),
|
||||||
"user" | "usr" => usr.push(fields[1].trim().to_string()),
|
"user" | "usr" => usr.push(entry),
|
||||||
_ => {
|
_ => {
|
||||||
log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}");
|
log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}");
|
||||||
}
|
}
|
||||||
@@ -137,47 +176,57 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
|||||||
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
|
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sys = sanitize_paths(sys);
|
let sys = sanitize_entries(sys);
|
||||||
let usr = sanitize_paths(usr);
|
let usr = sanitize_entries(usr);
|
||||||
if sys.is_empty() && usr.is_empty() {
|
if sys.is_empty() && usr.is_empty() {
|
||||||
return Err("CSV 文件中未找到有效路径".into());
|
return Err("CSV 文件中未找到有效路径".into());
|
||||||
}
|
}
|
||||||
Ok((sys, usr))
|
Ok((sys, usr))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_txt(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
|
fn import_txt(content: &str) -> Result<(Vec<ProfilePathEntry>, Vec<ProfilePathEntry>), String> {
|
||||||
let paths: Vec<String> = content
|
let entries: Vec<ProfilePathEntry> = content
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| l.trim().to_string())
|
.map(|l| l.trim().to_string())
|
||||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||||
|
.map(|path| ProfilePathEntry {
|
||||||
|
path,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let paths = sanitize_paths(paths);
|
let entries = sanitize_entries(entries);
|
||||||
if paths.is_empty() {
|
if entries.is_empty() {
|
||||||
return Err("TXT 文件中未找到路径".into());
|
return Err("TXT 文件中未找到路径".into());
|
||||||
}
|
}
|
||||||
// TXT 格式全部导入为用户路径
|
// TXT 格式全部导入为用户路径
|
||||||
Ok((vec![], paths))
|
Ok((vec![], entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导出 PATH 为指定格式字符串
|
/// 导出 PATH 为指定格式字符串
|
||||||
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<String, String> {
|
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<String, String> {
|
||||||
match format {
|
match format {
|
||||||
"json" => {
|
"json" => {
|
||||||
|
let to_entries = |paths: &[String]| -> Vec<serde_json::Value> {
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| serde_json::json!({"path": p, "enabled": true}))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
|
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
"system": sys,
|
"system": to_entries(sys),
|
||||||
"user": usr,
|
"user": to_entries(usr),
|
||||||
});
|
});
|
||||||
Ok(serde_json::to_string_pretty(&data).expect("JSON 序列化 Value 不应失败"))
|
Ok(serde_json::to_string_pretty(&data).expect("JSON 序列化 Value 不应失败"))
|
||||||
}
|
}
|
||||||
"csv" => {
|
"csv" => {
|
||||||
let mut out = String::from("type,path\n");
|
let mut out = String::from("type,path,enabled\n");
|
||||||
for p in sys {
|
for p in sys {
|
||||||
out.push_str(&format!("system,{}\n", p));
|
out.push_str(&format!("system,{},true\n", p));
|
||||||
}
|
}
|
||||||
for p in usr {
|
for p in usr {
|
||||||
out.push_str(&format!("user,{}\n", p));
|
out.push_str(&format!("user,{},true\n", p));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -205,12 +254,30 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(path: &str) -> ProfilePathEntry {
|
||||||
|
ProfilePathEntry {
|
||||||
|
path: path.into(),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_disabled(path: &str) -> ProfilePathEntry {
|
||||||
|
ProfilePathEntry {
|
||||||
|
path: path.into(),
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_json_valid() {
|
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();
|
let (sys, usr) = import_json(json).unwrap();
|
||||||
assert_eq!(sys, vec!["C:\\sys1", "C:\\sys2"]);
|
assert_eq!(sys.len(), 2);
|
||||||
assert_eq!(usr, vec!["D:\\usr1"]);
|
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]
|
#[test]
|
||||||
@@ -219,6 +286,15 @@ mod tests {
|
|||||||
assert!(sys.is_empty() && usr.is_empty());
|
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]
|
#[test]
|
||||||
fn import_json_missing_fields() {
|
fn import_json_missing_fields() {
|
||||||
let (sys, usr) = import_json(r#"{}"#).unwrap();
|
let (sys, usr) = import_json(r#"{}"#).unwrap();
|
||||||
@@ -228,16 +304,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn import_csv_valid() {
|
fn import_csv_valid() {
|
||||||
let csv = "type,path\nsystem,C:\\sys1\nuser,D:\\usr1\n";
|
let csv = "type,path\nsystem,C:\\sys1\nuser,D:\\usr1\n";
|
||||||
let (sys, _usr) = import_csv(csv).unwrap();
|
let (sys, usr) = import_csv(csv).unwrap();
|
||||||
assert_eq!(sys, vec!["C:\\sys1"]);
|
assert_eq!(sys.len(), 1);
|
||||||
assert_eq!(_usr, vec!["D:\\usr1"]);
|
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]
|
#[test]
|
||||||
fn import_csv_with_bom() {
|
fn import_csv_with_bom() {
|
||||||
let csv = "\u{FEFF}type,path\nsystem,C:\\sys1\n";
|
let csv = "\u{FEFF}type,path\nsystem,C:\\sys1\n";
|
||||||
let (sys, _) = import_csv(csv).unwrap();
|
let (sys, _) = import_csv(csv).unwrap();
|
||||||
assert_eq!(sys, vec!["C:\\sys1"]);
|
assert_eq!(sys[0].path, "C:\\sys1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -249,8 +329,27 @@ mod tests {
|
|||||||
fn import_csv_alternate_type_names() {
|
fn import_csv_alternate_type_names() {
|
||||||
let csv = "type,path\nsys,D:\\a\nusr,D:\\b\n";
|
let csv = "type,path\nsys,D:\\a\nusr,D:\\b\n";
|
||||||
let (sys, usr) = import_csv(csv).unwrap();
|
let (sys, usr) = import_csv(csv).unwrap();
|
||||||
assert_eq!(sys, vec!["D:\\a"]);
|
assert_eq!(sys[0].path, "D:\\a");
|
||||||
assert_eq!(usr, vec!["D:\\b"]);
|
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]
|
#[test]
|
||||||
@@ -259,16 +358,18 @@ mod tests {
|
|||||||
let usr: Vec<String> = vec![];
|
let usr: Vec<String> = vec![];
|
||||||
let exported = export_paths(&sys, &usr, "json").unwrap();
|
let exported = export_paths(&sys, &usr, "json").unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&exported).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]
|
#[test]
|
||||||
fn export_csv_roundtrip() {
|
fn export_csv_includes_enabled_column() {
|
||||||
let sys = vec!["C:\\a".into()];
|
let sys = vec!["C:\\a".into()];
|
||||||
let usr = vec!["D:\\b".into()];
|
let usr = vec!["D:\\b".into()];
|
||||||
let exported = export_paths(&sys, &usr, "csv").unwrap();
|
let exported = export_paths(&sys, &usr, "csv").unwrap();
|
||||||
assert!(exported.contains("system,C:\\a"));
|
assert!(exported.starts_with("type,path,enabled"));
|
||||||
assert!(exported.contains("user,D:\\b"));
|
assert!(exported.contains("system,C:\\a,true"));
|
||||||
|
assert!(exported.contains("user,D:\\b,true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -287,14 +388,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn import_paths_detects_format() {
|
fn import_paths_detects_format() {
|
||||||
let (sys, _) = import_paths("test.csv", "type,path\nsystem,C:\\x\n").unwrap();
|
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]
|
#[test]
|
||||||
fn import_paths_txt_to_user() {
|
fn import_paths_txt_to_user() {
|
||||||
let (sys, usr) = import_paths("test.txt", "C:\\x\nD:\\y\n").unwrap();
|
let (sys, usr) = import_paths("test.txt", "C:\\x\nD:\\y\n").unwrap();
|
||||||
assert!(sys.is_empty());
|
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]
|
#[test]
|
||||||
@@ -311,30 +414,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_json_filters_null_byte_paths() {
|
fn sanitize_entries_filters_null_byte_paths() {
|
||||||
// sanitize_paths 作为额外防线
|
let entries = vec![entry("C:\\safe"), entry("C:\\bad\0path")];
|
||||||
let paths = vec!["C:\\safe".into(), "C:\\bad\0path".into()];
|
let result = sanitize_entries(entries);
|
||||||
assert_eq!(sanitize_paths(paths), vec!["C:\\safe"]);
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].path, "C:\\safe");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_csv_filters_semicolon_paths() {
|
fn import_csv_filters_semicolon_paths() {
|
||||||
let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n";
|
let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n";
|
||||||
let (sys, _) = import_csv(csv).unwrap();
|
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]
|
#[test]
|
||||||
fn import_txt_trims_and_filters() {
|
fn import_txt_trims_and_filters() {
|
||||||
let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n";
|
let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n";
|
||||||
let (_, usr) = import_txt(txt).unwrap();
|
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]
|
#[test]
|
||||||
fn sanitize_paths_removes_empty_after_trim() {
|
fn sanitize_entries_removes_empty_after_trim() {
|
||||||
let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]);
|
let result = sanitize_entries(vec![entry(" "), entry("C:\\ok")]);
|
||||||
assert_eq!(result, vec!["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]
|
#[test]
|
||||||
@@ -362,6 +477,15 @@ mod tests {
|
|||||||
fn import_csv_quoted_comma_path() {
|
fn import_csv_quoted_comma_path() {
|
||||||
let csv = "type,path\nsystem,\"C:\\Program Files, Inc\\bin\"\n";
|
let csv = "type,path\nsystem,\"C:\\Program Files, Inc\\bin\"\n";
|
||||||
let (sys, _) = import_csv(csv).unwrap();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
fn profiles_dir() -> PathBuf {
|
fn profiles_dir() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
@@ -10,6 +11,11 @@ fn profiles_dir() -> PathBuf {
|
|||||||
.join("profiles")
|
.join("profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn profiles_dir() -> PathBuf {
|
||||||
|
std::env::temp_dir().join("patheditor_test_profiles")
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_profile_name(name: &str) -> Result<(), String> {
|
fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err("配置名称不能为空".into());
|
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(old_name)?;
|
||||||
validate_profile_name(new_name)?;
|
validate_profile_name(new_name)?;
|
||||||
let old_path = profile_path(old_name);
|
let old_path = profile_path(old_name);
|
||||||
|
let new_path = profile_path(new_name);
|
||||||
if !old_path.exists() {
|
if !old_path.exists() {
|
||||||
return Err(format!("配置文件不存在: {}", old_name));
|
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(
|
let mut data: ProfileData = serde_json::from_str(
|
||||||
&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?,
|
&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.name = new_name.to_string();
|
||||||
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").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 =
|
let json =
|
||||||
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
||||||
atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
|
atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
|
||||||
|
|||||||
+18
-8
@@ -50,10 +50,6 @@ fn list_exes(dir: &str) -> Vec<String> {
|
|||||||
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
|
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
|
||||||
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
|
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
|
||||||
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
|
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
|
||||||
// 并行扫描各目录(限制并发数)
|
|
||||||
let max_threads = std::thread::available_parallelism()
|
|
||||||
.map(|n| n.get())
|
|
||||||
.unwrap_or(4);
|
|
||||||
let results: Vec<(usize, String, Vec<String>)> = std::thread::scope(|s| {
|
let results: Vec<(usize, String, Vec<String>)> = std::thread::scope(|s| {
|
||||||
let handles: Vec<_> = paths
|
let handles: Vec<_> = paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -66,8 +62,6 @@ pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String>
|
|||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("线程扫描失败: {}", e))?;
|
.map_err(|e| format!("线程扫描失败: {}", e))?;
|
||||||
// max_threads 用于限制 scope 外的并行度,实际线程由 scope 调度
|
|
||||||
let _ = max_threads;
|
|
||||||
|
|
||||||
// 合并: exe_name (小写) → [(priority, dir)]
|
// 合并: exe_name (小写) → [(priority, dir)]
|
||||||
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
|
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
|
||||||
@@ -155,13 +149,29 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
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 dir = std::env::temp_dir().join(format!("patheditor_test_{}", prefix));
|
||||||
|
let _ = fs::remove_dir_all(&dir); // 清理残留
|
||||||
fs::create_dir_all(&dir).unwrap();
|
fs::create_dir_all(&dir).unwrap();
|
||||||
for name in exe_names {
|
for name in exe_names {
|
||||||
fs::write(dir.join(name), b"fake").unwrap();
|
fs::write(dir.join(name), b"fake").unwrap();
|
||||||
}
|
}
|
||||||
dir
|
TempDirGuard(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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 代码审查*
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'gui'] },
|
{ ignores: ['dist', 'gui', 'target', 'test-results', 'e2e', '*.config.*'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable-x86_64-pc-windows-gnu"
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { TargetType } from '@/core/undo-redo';
|
import { TargetType } from '@/core/undo-redo';
|
||||||
|
import { usePathValidation } from '@/hooks/use-path-validation';
|
||||||
|
import type { ValidationState } from '@/hooks/use-path-validation';
|
||||||
|
|
||||||
interface PathTableProps {
|
interface PathTableProps {
|
||||||
tabId: 'system' | 'user';
|
tabId: 'system' | 'user';
|
||||||
@@ -14,9 +15,6 @@ interface PathRow {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationState = 'valid' | 'invalid' | 'unknown';
|
|
||||||
const DEFAULT_VALIDATION_STATE: ValidationState = 'valid';
|
|
||||||
|
|
||||||
export function PathTable({ tabId }: PathTableProps) {
|
export function PathTable({ tabId }: PathTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
@@ -29,42 +27,9 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const paths = tabId === 'system' ? sysPaths : userPaths;
|
const paths = tabId === 'system' ? sysPaths : userPaths;
|
||||||
const isActive = activeTab === tabId;
|
const isActive = activeTab === tabId;
|
||||||
|
|
||||||
// 本次会话中已验证过的路径缓存(key=path, value=ValidationState)
|
const { validationCache, expandedCache } = usePathValidation(paths);
|
||||||
const [validationCache, setValidationCache] = useState<Map<string, ValidationState>>(new Map());
|
|
||||||
// 环境变量展开结果缓存(key=path, value=expanded)
|
|
||||||
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
const validatedRef = useRef<Set<string>>(new Set());
|
// 搜索过滤
|
||||||
const expandedRef = useRef<Set<string>>(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<PathRow[]>(() => {
|
const filtered = useMemo<PathRow[]>(() => {
|
||||||
if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled }));
|
if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled }));
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
@@ -76,79 +41,15 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
return result;
|
return result;
|
||||||
}, [paths, searchQuery]);
|
}, [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 validations = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return filtered.map(({ path }) => {
|
return filtered.map(({ path }) => {
|
||||||
const lower = path.toLowerCase();
|
const lower = path.toLowerCase();
|
||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
return {
|
const state: ValidationState = validationCache.get(path) ?? 'valid';
|
||||||
state: validationCache.get(path) ?? DEFAULT_VALIDATION_STATE,
|
return { state, isDuplicate, isEnvVar: path.includes('%') };
|
||||||
isDuplicate,
|
|
||||||
isEnvVar: path.includes('%'),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [filtered, validationCache]);
|
}, [filtered, validationCache]);
|
||||||
|
|
||||||
|
|||||||
@@ -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<Set<string>>(new Set());
|
||||||
|
const expandedRef = useRef<Set<string>>(new Set());
|
||||||
|
const [validationCache, setValidationCache] = useState<Map<string, ValidationState>>(new Map());
|
||||||
|
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user