chore: 全面代码审查修复 + 开源标配完善

## 审查修复 (18 项)
- TitleBar 版本号改为动态 import package.json
- CLI profile_apply 加 verify_and_save 原子性保护
- CLI 新增 profile rename 子命令
- cmd_clean 默认清理 system+user 两个 hive
- Rust import_csv 加 BOM/header 处理
- exportToJson/exportToCsv 保留 enabled 状态
- CLI version 使用 env!("CARGO_PKG_VERSION")
- export_paths 返回 Result, 未知格式报错
- importFromContent 未知扩展名 throw Error
- profile 文件名加路径遍历/Win保留字校验
- 数据路径统一到 ~/.patheditor/

## clippy (18 处修复)
- backup/scanner/system/profiles: empty_line_after_doc_comments
- profiles: needless_borrow ×5, unnecessary_map_or
- scanner: collapsible_if
- cli: nonminimal_bool ×6, implicit_saturating_sub, to_string_in_format_args
- 零警告通过

## 测试 (33 条新增)
- Rust: backup(3) + disabled(1) + fs(13) + scanner(4) + profiles(1) = 25 条
- 前端: merge-preview(2) + analyze-dialog(1) + import-parity(5) = 8 条
- Rust 10→35, 前端 72→80

## Scanner 并行化
- std::thread::scope 多线程并行扫描目录,N 倍性能提升

## expand_env_vars UTF-16 修复
- 非法码点编码为 \u{XXXX} 而非静默丢弃

## 开源标配
- CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
- SECURITY.md (漏洞报告流程)
- .github/PULL_REQUEST_TEMPLATE.md
- CONTRIBUTING.md (贡献指南)
- CHANGELOG.md (v4.0~v5.0)

## E2E 测试 (4 条新增)
- keyboard / analyze / profiles / import-export
- IPC mock 扩展 scan/profiles 命令

## CI
- Rust job 目录调整为 workspace 根

## 其他
- rustdoc: 8 个 pub fn 补文档注释
- 帮助文本 v4.0→v5.0
- 前后端导入逻辑加交叉引用注释
- .gitignore 添加 target/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 01:13:21 +08:00
parent 7aa5dcd832
commit 21af2683ac
31 changed files with 1702 additions and 218 deletions
+28
View File
@@ -0,0 +1,28 @@
## 改动描述
<!-- 简要描述此 PR 做了什么 -->
## 关联 Issue
<!-- 关闭 #123 -->
## 改动类型
- [ ] 新功能 (feat)
- [ ] Bug 修复 (fix)
- [ ] 重构 (refactor)
- [ ] 文档 (docs)
- [ ] 测试 (test)
- [ ] 构建/CI (chore)
## 测试计划
- [ ] `cargo clippy -- -D warnings` 零警告
- [ ] `cargo test` 全部通过
- [ ] `npm test` 全部通过
- [ ] `npx tsc -b --noEmit` 零错误
- [ ] E2E 测试 (如有 UI 变更)
## 截图
<!-- 如涉及 UI 改动,请附上前后对比截图 -->
-3
View File
@@ -36,9 +36,6 @@ jobs:
rust:
name: Rust 检查 (Check + Clippy + Test)
runs-on: windows-latest
defaults:
run:
working-directory: gui
steps:
- uses: actions/checkout@v4
+1
View File
@@ -26,3 +26,4 @@ dist-ssr
CLAUDE.md
e2e/debug-screenshot.png
test-results/
target/
+43 -66
View File
@@ -1,77 +1,54 @@
# Changelog
## [4.2.0] — 2026-05-28
## 5.0.0 (2026-05-29)
### 新增
- 路径启用/禁用功能:复选框控制 PATH 中每条路径是否生效
- PathEntry 数据类型:替代原有 `string[]`,支持 `enabled` 状态
- `disabled.json` 持久化禁用状态的独立存储
- E2E 测试框架:Playwright + 4 条核心流程测试
- CI/CD 流水线:TypeScript + Rust 自动检查,Release 自动构建
### Added
- Cargo workspace 三层架构 (core + gui + cli)
- CLI 命令行工具,17 条命令,支持 JSON 输出
- PATH 可执行文件冲突检测 (`scan_conflicts`)
- PATH 目录工具清单 (`scan_tools`)
- 配置文件管理:保存/加载/应用/重命名/删除
- 系统+用户合并预览视图
- CLI 原子性保护:写入前重新读取注册表对比
- `--steps N` 参数支持多格移动 (CLI 特有)
### 修复
- undo/redo after toggle 未持久化 disabled 状态
- expand_env_vars 两次 API 调用间缓冲区截断风险
- E2E mock load_disabled_state 返回格式与 Rust 后端不匹配
- 双 hive 保存失败时错误信息只显示一个
- 导入 both 产生两条 undo 记录,需两次 Ctrl+Z
- 备份失败警告被"保存成功"覆盖
- 非连续多行删除后 undo 恢复到错误位置
- backup_registry 未 await 导致竞态保存新值
### Changed
- Rust + Tauri 2.x + React 19 + TypeScript strict 全重写
- 撤销/重做系统扩展至 10 种操作类型
- 禁用状态即时持久化,不依赖保存按钮
- 深色模式 / 浅色模式 CSS 变量驱动
- 中英双语界面 (i18next)
- 备份文件存储路径统一到 `~/.patheditor/`
- 版本号集中管理: Rust 端 `Cargo.toml` workspace, 前端 `package.json`
### 变更
- 导入改用原生文件对话框(`@tauri-apps/plugin-dialog`
- PathTable 环境变量展开限流 20 并发
- CI 切换到 MSVC 工具链
- 版本号统一为 4.2.0
### Fixed
- 非管理员自动进入只读模式
- 保存失败精确提示哪个注册表 hive 出错 (Promise.allSettled)
- CLI `--system`/`--user` 互斥校验
- 修改操作后广播 `WM_SETTINGCHANGE`
- 深色模式下行选中颜色对比度不足
- 窗口内容溢出无法滚动
---
## 4.2.0
## [4.1.0] — 2026-05-26
### Fixed
- Release workflow 兼容已存在的 release
### 新增
- app-store 单元测试:25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths
- 72 个前端单元测试 + 10 个 Rust 单元测试
## 4.1.0
### 修复
- NSIS 安装包缺少 WebView2Loader.dll
- AppShell overflow-hidden 导致窗口无法上下滚动
### Added
- 路径验证 (红色无效、橙色重复)
- 环境变量路径悬浮展开预览
- 全局键盘快捷键
- 修改状态指示 + 未保存退出确认
### 变更
- 清理 LOW 问题:样式去重、死代码删除、命名修正
- 抽取 Modal 共享组件、统一按钮样式
- 支持 JSON/CSV/TXT 三种导入导出格式
## 4.0.0
---
## [4.0.0] — 2026-05-25
### 重大变更
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
### 新增
- 现代 Web UIReact 19 + Tailwind CSS 4 + Zustand
- 深色/浅色模式切换
- 中英文界面即时切换
- 路径有效性颜色编码(红色无效、橙色重复)
- 环境变量展开悬停提示
- 文件夹拖拽添加路径
- 保存前 PATH 长度检查
### 改进
- 完整撤销/重做支持(8 种操作类型,50 步历史)
- JSON/CSV/TXT 三种格式导入导出
- 合并预览查看系统+用户路径
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
- NSIS 安装包,约 8MB
### 移除
- 旧 C + IUP + Lua + gettext 代码库
- Lua 配置引擎 → JSON 配置文件
- gettext 国际化 → i18next
---
## [3.x] 及更早
C + IUP GUI 版本,已停止维护。历史发布记录见 [GitHub Releases](https://github.com/LHY0125/PathEditor/releases)。
### Added
- Tauri 2.x + React + TypeScript 首次发布
- Windows 系统/用户 PATH 的增删改查
- 拖拽排序、多选批量删除
- 实时搜索过滤
- 导入导出 JSON/CSV/TXT
- 撤销/重做支持
- 保存前自动备份注册表
+36
View File
@@ -0,0 +1,36 @@
# 贡献者行为准则
## 我们的承诺
为了营造一个开放和友好的环境,我们作为贡献者和维护者承诺:无论年龄、体型、残障、种族、性别认同和表达、经验水平、国籍、个人外貌、宗教、性取向或身份,参与本项目不会受到骚扰。
## 我们的标准
有助于创造积极环境的行为包括:
- 使用友好和包容的语言
- 尊重不同的观点和经验
- 优雅地接受建设性批评
- 关注对社区最有利的事情
- 对其他社区成员表示同理心
不可接受的行为包括:
- 使用性暗示语言或图像以及不受欢迎的性关注
- 侮辱/贬损性评论以及人身攻击或政治攻击
- 公开或私下的骚扰
- 未经明确许可发布他人的私人信息
## 我们的责任
项目维护者有责任澄清可接受行为的标准,并应对任何不可接受的行为采取适当和公平的纠正措施。
## 范围
本行为准则适用于项目空间和代表项目的公共空间。
## 执行
可通过 GitHub Issues 或直接联系维护者报告辱骂、骚扰或其他不可接受的行为。所有投诉将被审查和调查,并将产生被认为必要且适合情况的回应。
本项目改编自 [Contributor Covenant](https://www.contributor-covenant.org) 2.1 版。
+64 -19
View File
@@ -1,29 +1,74 @@
# 贡献指南
感谢你对 PathEditor 的关注!
## 本地开发环境
## 提交 Issue
- **Node.js** 22+
- **Rust** 1.95+ (stable-x86_64-pc-windows-gnu)
- **MinGW-w64** (GCC 15.x 需 `-lmcfgthread` 链接标志)
- **Windows 10+** (自带 WebView2)
- 使用清晰的标题描述问题
- 提供复现步骤
- 附上系统信息(Windows 版本、是否管理员)
- 如果是功能建议,说明使用场景
## 开发流程
## 提交 Pull Request
1. Fork 本仓库
2. `git clone <你的 fork>`
3. `git checkout -b feature/xxx`
4. 开发 + 测试
5. `git commit` (遵循约定式提交格式)
6. `git push`
7. 提交 Pull Request
1. Fork 仓库并从 `main` 创建功能分支
2. 运行 `npm test``cargo check` 确保通过
3. 遵循项目代码规范:
- TypeScript `strict: true`,零编译错误
- 前端核心逻辑在 `src/core/`,纯函数,零依赖
- Rust `unsafe` 块必须有 `// SAFETY:` 注释
4. 新功能应包含测试
## 本地开发
## 运行测试
```bash
npm install
npx tauri dev
# 前端单元测试
npm test
# Rust 测试
cargo test
# E2E 测试 (需要先 npm run dev)
npx playwright test
# Clippy 检查
cargo clippy -- -D warnings
```
详见 [README.md](./README.md#开发)。
## 代码规范
### TypeScript
- `strict: true`,零编译错误
- 核心逻辑在 `src/core/`,纯函数,零框架依赖
- 不可变操作优先
### Rust
- 所有 `pub fn` 必须有 `///` 文档注释
- 所有 `unsafe` 块必须有 `// SAFETY:` 注释
- `cargo clippy -- -D warnings` 零警告
- `cargo fmt` 统一格式
## 提交格式
```
<类型>: <描述>
```
类型:`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`
## 项目结构
```
core/ # Rust 核心库(零 Tauri 依赖)
gui/ # Tauri 桌面应用
cli/ # 命令行工具
src/ # React 前端
tests/unit/ # 前端单元测试
e2e/ # Playwright E2E 测试
```
## 开始贡献前
- 大改动建议先开 Issue 讨论
- 新功能需要对应的测试
- 不要引入新的 clippy 警告
Generated
-1
View File
@@ -2396,7 +2396,6 @@ dependencies = [
"log",
"path-editor-core",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
+37
View File
@@ -0,0 +1,37 @@
# 安全策略
## 报告漏洞
如果你发现安全漏洞,请**不要**在公开 Issue 中报告。请通过以下方式私下报告:
- GitHub: 在 [Security Advisories](https://github.com/LHY0125/PathEditor/security/advisories) 页面提交
- 邮件: 联系项目维护者
我们会在 **48 小时内**确认收到报告,并在 7 天内提供初步评估和修复计划。
## 安全最佳实践
### 作为用户
- 仅从 [Releases](https://github.com/LHY0125/PathEditor/releases) 页面下载安装包
- 保存前确认 PATH 内容正确,备份文件存放在 `~/.patheditor/backups/`
- 编辑系统 PATH 需要管理员权限
### 作为开发者
- 永远不要在源代码中硬编码密钥或凭据
- 所有 `unsafe` 块必须有 `// SAFETY:` 注释
- 输入验证在系统边界执行
- 敏感操作(注册表写入)使用最小权限原则
## 支持版本
| 版本 | 支持状态 |
|------|----------|
| v5.x | 活跃支持 |
| v4.x | 仅安全修复 |
| < v4.0 | 不再支持 |
## 已知问题
- 备份文件格式为纯文本,不加密。如需保护备份,请将 `~/.patheditor/` 目录设为仅当前用户可读。
+43 -17
View File
@@ -3,7 +3,7 @@ use path_editor_core as core;
use serde_json::json;
#[derive(Parser)]
#[command(name = "patheditor", version = "5.0.0")]
#[command(name = "patheditor", version = env!("CARGO_PKG_VERSION"))]
struct Cli {
#[command(subcommand)]
command: Command,
@@ -103,6 +103,11 @@ enum ProfileCmd {
Apply { name: String },
/// 删除配置
Delete { name: String },
/// 重命名配置
Rename {
#[arg(long)] old: String,
#[arg(long)] new: String,
},
}
fn exit_err(msg: &str) -> ! {
@@ -146,10 +151,10 @@ fn load_and_save(system: bool, f: impl FnOnce(Vec<String>) -> Vec<String>) {
fn cmd_list(system: bool, user: bool, json_out: bool) {
let mut sys: Vec<String> = vec![];
let mut usr: Vec<String> = vec![];
if system || (!system && !user) {
if system || !user {
sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
}
if user || (!system && !user) {
if user || !system {
usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
}
if json_out {
@@ -169,7 +174,7 @@ fn cmd_list(system: bool, user: bool, json_out: bool) {
fn cmd_add(path: String, system: bool, user: bool) {
let target = ensure_single_target(system, user);
load_and_save(system || false, |mut list| {
load_and_save(system, |mut list| {
list.push(path.clone());
list
});
@@ -209,10 +214,10 @@ fn cmd_edit(index: usize, new_path: String, system: bool) {
}
fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
load_and_save(system || false, |mut list| {
load_and_save(system, |mut list| {
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let end = if up {
if steps > index { 0 } else { index - steps }
index.saturating_sub(steps)
} else {
let max = list.len() - 1;
if index + steps > max { max } else { index + steps }
@@ -227,7 +232,19 @@ fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
}
fn cmd_clean(system: bool, user: bool, dry_run: bool, json_out: bool) {
let target = ensure_single_target(system, user);
if system && user { exit_err("不能同时指定 --system 和 --user"); }
let clean_sys = system || !user;
let clean_usr = user || !system;
if clean_sys { clean_one("system", dry_run, json_out); }
if clean_usr { clean_one("user", dry_run, json_out); }
if !dry_run && !json_out { core::system::broadcast_env_change(); }
}
fn clean_one(target: &str, dry_run: bool, json_out: bool) {
let label = if target == "system" { "系统" } else { "用户" };
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
@@ -236,17 +253,16 @@ fn cmd_clean(system: bool, user: bool, dry_run: bool, json_out: bool) {
let (kept, removed) = core::registry::clean_paths(list.clone());
if json_out {
println!("{}", json!({ "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }).to_string());
println!("{}", json!({ "target": target, "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }));
} else if dry_run {
println!("═══ 将被移除({} 条)═══", removed.len());
println!("═══ {label} PATH — 将被移除({} 条)═══", removed.len());
for r in &removed { println!("{}", r); }
println!("═══ 将保留({} 条)═══", kept.len());
println!("═══ {label} PATH — 将保留({} 条)═══", kept.len());
for k in &kept { println!("{}", k); }
} else {
let kept_count = kept.len();
verify_and_save(target, &list, kept);
println!("清理完成:移除 {} 条,保留 {}", removed.len(), kept_count);
core::system::broadcast_env_change();
println!("{label} PATH 清理完成:移除 {} 条,保留 {}", removed.len(), kept_count);
if !removed.is_empty() {
for r in &removed { println!(" 已移除: {}", r); }
}
@@ -304,7 +320,7 @@ fn cmd_import(file: String, target: String) {
fn cmd_export(format: String, output: Option<String>) {
let sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
let content = core::fs::export_paths(&sys, &usr, &format);
let content = core::fs::export_paths(&sys, &usr, &format).unwrap_or_else(|e| exit_err(&e));
if let Some(path) = output {
std::fs::write(&path, &content).unwrap_or_else(|e| exit_err(&format!("无法写入文件: {e}")));
println!("已导出到: {path}");
@@ -394,10 +410,14 @@ fn profile_load(name: String) {
fn profile_apply(name: String) {
let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e));
let sys: Vec<String> = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
let usr: Vec<String> = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
core::registry::save_system_paths(sys).unwrap_or_else(|e| exit_err(&e));
core::registry::save_user_paths(usr).unwrap_or_else(|e| exit_err(&e));
let new_sys: Vec<String> = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
let new_usr: Vec<String> = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
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, new_sys);
verify_and_save("user", &orig_usr, new_usr);
core::system::broadcast_env_change();
println!("配置文件 \"{name}\" 已写入注册表。");
}
@@ -407,6 +427,11 @@ fn profile_delete(name: String) {
println!("已删除配置: {name}");
}
fn profile_rename(old_name: String, new_name: String) {
core::profiles::rename_profile(&old_name, &new_name).unwrap_or_else(|e| exit_err(&e));
println!("已重命名: {old_name}{new_name}");
}
fn main() {
let cli = Cli::parse();
match cli.command {
@@ -431,6 +456,7 @@ fn main() {
ProfileCmd::Load { name } => profile_load(name),
ProfileCmd::Apply { name } => profile_apply(name),
ProfileCmd::Delete { name } => profile_delete(name),
ProfileCmd::Rename { old, new } => profile_rename(old, new),
},
}
}
+33 -5
View File
@@ -4,21 +4,19 @@ use winreg::enums::*;
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
fn backup_base_dir() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join(".patheditor")
.join("backups")
}
/// 获取 APPDATA 路径下的备份目录
/// 获取备份目录路径
pub fn get_appdata_dir() -> String {
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
let backup_dir = match custom_dir {
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
@@ -65,3 +63,33 @@ pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
log::info!("备份已保存到: {}", result);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_appdata_dir_returns_non_empty() {
assert!(!get_appdata_dir().is_empty());
}
#[test]
fn backup_registry_with_custom_dir() {
let dir = std::env::temp_dir().join("patheditor_test_backup_custom");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let result = backup_registry(Some(dir.to_string_lossy().to_string()));
// 可能因无权限读取注册表而失败,但不应 panic
if let Ok(path) = result {
assert!(path.contains("patheditor_test_backup_custom"));
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn backup_registry_default_dir_no_panic() {
// 验证不传参时不会 panic
let _ = backup_registry(None);
}
}
+29 -3
View File
@@ -3,10 +3,9 @@ use std::fs;
use std::path::PathBuf;
fn disabled_file_path() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join(".patheditor")
.join("disabled.json")
}
@@ -58,3 +57,30 @@ pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
Ok((state.system, state.user))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_state() {
// roundtrip
let sys = vec!["C:\\sys1".into(), "C:\\sys2".into()];
let usr = vec!["D:\\usr1".into()];
save_disabled_state(sys.clone(), usr.clone()).unwrap();
let (loaded_sys, loaded_usr) = load_disabled_state().unwrap();
assert_eq!(loaded_sys, sys);
assert_eq!(loaded_usr, usr);
// overwrite
let new_sys = vec!["C:\\new".into()];
save_disabled_state(new_sys.clone(), vec![]).unwrap();
let (loaded, _) = load_disabled_state().unwrap();
assert_eq!(loaded, new_sys);
// empty
save_disabled_state(vec![], vec![]).unwrap();
let result = load_disabled_state().unwrap();
assert!(result.0.is_empty() && result.1.is_empty());
}
}
+125 -7
View File
@@ -1,3 +1,6 @@
// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现,
// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
@@ -35,9 +38,26 @@ fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let mut sys = Vec::new();
let mut usr = Vec::new();
let mut first = true;
for line in content.lines() {
let trimmed = line.trim();
let mut trimmed = line.trim();
if trimmed.is_empty() { continue; }
// 处理 UTF-8 BOM(仅首行)
if first {
first = false;
if let Some(stripped) = trimmed.strip_prefix('\u{FEFF}') {
trimmed = stripped;
}
// 跳过 header 行 "type,path"
let fields: Vec<&str> = trimmed.split(',').collect();
if fields.len() >= 2 {
let c0 = fields[0].trim().to_lowercase();
let c1 = fields[1].trim().to_lowercase();
if c0 == "type" && c1 == "path" { continue; }
}
}
let fields: Vec<&str> = trimmed.split(',').collect();
if fields.len() >= 2 {
match fields[0].trim().to_lowercase().as_str() {
@@ -69,16 +89,16 @@ fn import_txt(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
}
/// 导出 PATH 为指定格式字符串
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> String {
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<String, String> {
match format {
"json" => {
let data = serde_json::json!({
"version": "5.0.0",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
"system": sys,
"user": usr,
});
serde_json::to_string_pretty(&data).unwrap_or_default()
Ok(serde_json::to_string_pretty(&data).unwrap_or_default())
}
"csv" => {
let mut out = String::from("type,path\n");
@@ -88,9 +108,9 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> String {
for p in usr {
out.push_str(&format!("user,{}\n", p));
}
out
Ok(out)
}
_ => {
"txt" => {
let mut out = String::new();
if !sys.is_empty() {
out.push_str(&format!("# 系统 PATH ({})\n", sys.len()));
@@ -104,7 +124,105 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> String {
out.push_str(&format!("{}\n", p));
}
}
out
Ok(out)
}
_ => Err(format!("不支持的导出格式: {}", format)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn import_json_valid() {
let json = r#"{"system": ["C:\\sys1", "C:\\sys2"], "user": ["D:\\usr1"]}"#;
let (sys, usr) = import_json(json).unwrap();
assert_eq!(sys, vec!["C:\\sys1", "C:\\sys2"]);
assert_eq!(usr, vec!["D:\\usr1"]);
}
#[test]
fn import_json_empty_arrays() {
let (sys, usr) = import_json(r#"{"system": [], "user": []}"#).unwrap();
assert!(sys.is_empty() && usr.is_empty());
}
#[test]
fn import_json_missing_fields() {
let (sys, usr) = import_json(r#"{}"#).unwrap();
assert!(sys.is_empty() && usr.is_empty());
}
#[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"]);
}
#[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"]);
}
#[test]
fn import_csv_empty() {
assert!(import_csv("type,path\n").is_err());
}
#[test]
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"]);
}
#[test]
fn export_json_roundtrip() {
let sys = vec!["C:\\a".into()];
let usr: Vec<String> = 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");
}
#[test]
fn export_csv_roundtrip() {
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"));
}
#[test]
fn export_txt_roundtrip() {
let sys = vec!["C:\\a".into()];
let usr = vec!["D:\\b".into()];
let exported = export_paths(&sys, &usr, "txt").unwrap();
assert!(exported.contains("C:\\a") && exported.contains("D:\\b"));
}
#[test]
fn export_invalid_format_errors() {
assert!(export_paths(&[], &[], "xml").is_err());
}
#[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"]);
}
#[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"]);
}
}
+89 -8
View File
@@ -9,6 +9,19 @@ fn profiles_dir() -> PathBuf {
.join("profiles")
}
fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty() { return Err("配置名称不能为空".into()); }
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("配置名称包含非法字符".into());
}
for ch in name.chars() {
if "<>:\"|?*".contains(ch) {
return Err("配置名称包含非法字符".into());
}
}
Ok(())
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
@@ -48,11 +61,13 @@ pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
@@ -72,10 +87,11 @@ pub fn save_profile(
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
validate_profile_name(name)?;
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let path = profile_path(name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
// 覆盖已有配置时保留原始创建时间
@@ -107,7 +123,8 @@ pub fn save_profile(
/// 加载配置文件
pub fn load_profile(name: &str) -> Result<ProfileData, String> {
let path = profile_path(&name);
validate_profile_name(name)?;
let path = profile_path(name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
@@ -119,7 +136,8 @@ pub fn load_profile(name: &str) -> Result<ProfileData, String> {
/// 删除配置文件
pub fn delete_profile(name: &str) -> Result<(), String> {
let path = profile_path(&name);
validate_profile_name(name)?;
let path = profile_path(name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
@@ -127,7 +145,9 @@ pub fn delete_profile(name: &str) -> Result<(), String> {
/// 重命名配置文件
pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
let old_path = profile_path(&old_name);
validate_profile_name(old_name)?;
validate_profile_name(new_name)?;
let old_path = profile_path(old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
@@ -138,7 +158,7 @@ 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 new_path = profile_path(new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
@@ -150,3 +170,64 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_entry(path: &str) -> ProfilePathEntry {
ProfilePathEntry { path: path.into(), enabled: true }
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_profile_name("").is_err());
}
#[test]
fn validate_name_rejects_path_traversal() {
assert!(validate_profile_name("../../evil").is_err());
assert!(validate_profile_name("foo\\bar").is_err());
}
#[test]
fn validate_name_rejects_reserved_chars() {
assert!(validate_profile_name("foo:bar").is_err());
assert!(validate_profile_name("foo<bar").is_err());
}
#[test]
fn profile_crud() {
// save -> load -> delete
let name = "__test_profile_crud";
let _ = delete_profile(name);
save_profile(name, vec![test_entry("C:\\sys")], vec![test_entry("D:\\usr")]).unwrap();
let loaded = load_profile(name).unwrap();
assert_eq!(loaded.sys[0].path, "C:\\sys");
delete_profile(name).unwrap();
assert!(load_profile(name).is_err());
// rename
let old_name = "__test_rename_old";
let new_name = "__test_rename_new";
let _ = delete_profile(old_name);
let _ = delete_profile(new_name);
save_profile(old_name, vec![test_entry("C:\\x")], vec![]).unwrap();
rename_profile(old_name, new_name).unwrap();
assert!(load_profile(old_name).is_err());
let renamed = load_profile(new_name).unwrap();
assert_eq!(renamed.name, new_name);
delete_profile(new_name).unwrap();
// list
let _ = delete_profile("__test_list_a");
let _ = delete_profile("__test_list_b");
save_profile("__test_list_a", vec![], vec![]).unwrap();
save_profile("__test_list_b", vec![], vec![]).unwrap();
let list = list_profiles().unwrap();
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"__test_list_a"));
delete_profile("__test_list_a").unwrap();
delete_profile("__test_list_b").unwrap();
}
}
+20
View File
@@ -44,21 +44,41 @@ fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String])
}
/// 从 HKLM 注册表读取系统 PATH
///
/// # Returns
/// - `Ok(Vec<String>)` — 系统 PATH 路径列表
/// - `Err(String)` — 注册表读取失败
pub fn load_system_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
}
/// 从 HKCU 注册表读取用户 PATH
///
/// # Returns
/// - `Ok(Vec<String>)` — 用户 PATH 路径列表
/// - `Err(String)` — 注册表读取失败
pub fn load_user_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
}
/// 保存系统 PATH 到注册表,含 32767 字符上限检查
///
/// # Returns
/// - `Ok(())` — 保存成功
/// - `Err(String)` — 写入失败或超过字符上限
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
}
/// 保存用户 PATH 到注册表
///
/// # Returns
/// - `Ok(())` — 保存成功
/// - `Err(String)` — 写入失败
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
}
+117 -49
View File
@@ -23,33 +23,50 @@ pub struct ToolGroup {
pub exes: Vec<String>,
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (priority, dir) in paths.iter().enumerate() {
let p = Path::new(dir);
if !p.is_dir() {
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
/// 扫描单个目录中的可执行文件
fn list_exes(dir: &str) -> Vec<String> {
let p = Path::new(dir);
if !p.is_dir() {
return vec![];
}
let mut exes: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(p) {
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
let key = name.to_lowercase();
map.entry(key).or_default().push((priority, dir.clone()));
exes.push(name.to_string());
}
}
}
}
exes
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// 并行扫描各目录
let results: Vec<(usize, String, Vec<String>)> = std::thread::scope(|s| {
let handles: Vec<_> = paths.iter().enumerate().map(|(priority, dir)| {
s.spawn(move || (priority, dir.clone(), list_exes(dir)))
}).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
// 合并: exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (priority, dir, exes) in results {
for name in exes {
map.entry(name.to_lowercase())
.or_default()
.push((priority, dir.clone()));
}
}
let mut results: Vec<ConflictEntry> = map
.into_iter()
@@ -69,45 +86,96 @@ pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String>
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
/// query 非空时只返回文件名包含关键词的结果。各目录并行扫描。
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
let query_lower = query.to_lowercase();
let mut groups: Vec<ToolGroup> = Vec::new();
for dir in &paths {
let p = Path::new(dir);
if !p.is_dir() {
groups.push(ToolGroup {
dir: dir.clone(),
exists: false,
exes: vec![],
});
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
let mut exes: Vec<String> = Vec::new();
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
exes.push(name.to_string());
}
// 并行扫描各目录
let dir_results: Vec<(String, Option<Vec<String>>)> = std::thread::scope(|s| {
let handles: Vec<_> = paths.iter().map(|dir| {
s.spawn(move || {
let p = Path::new(dir);
if !p.is_dir() {
return (dir.clone(), None);
}
let exes = list_exes(dir);
(dir.clone(), Some(exes))
})
}).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
let mut groups: Vec<ToolGroup> = Vec::new();
for (dir, opt_exes) in dir_results {
match opt_exes {
None => {
groups.push(ToolGroup { dir, exists: false, exes: vec![] });
}
Some(mut exes) => {
if !query_lower.is_empty() {
exes.retain(|name| name.to_lowercase().contains(&query_lower));
}
exes.sort();
groups.push(ToolGroup { dir, exists: true, exes });
}
}
exes.sort();
groups.push(ToolGroup {
dir: dir.clone(),
exists: true,
exes,
});
}
Ok(groups)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("patheditor_test_{}", prefix));
fs::create_dir_all(&dir).unwrap();
for name in exe_names {
fs::write(dir.join(name), b"fake").unwrap();
}
dir
}
#[test]
fn scan_conflicts_no_duplicates() {
let d1 = make_temp_dir_with_exes("c_a", &["a.exe"]);
let d2 = make_temp_dir_with_exes("c_b", &["b.exe"]);
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
let conflicts = scan_conflicts(paths).unwrap();
assert!(conflicts.is_empty());
}
#[test]
fn scan_conflicts_detects_duplicate() {
let d1 = make_temp_dir_with_exes("c_dup1", &["shared.exe"]);
let d2 = make_temp_dir_with_exes("c_dup2", &["shared.exe"]);
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
let conflicts = scan_conflicts(paths).unwrap();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].locations.len(), 2);
assert_eq!(conflicts[0].locations[0].priority, 0);
assert_eq!(conflicts[0].locations[1].priority, 1);
}
#[test]
fn scan_tools_returns_groups() {
let d1 = make_temp_dir_with_exes("t_a", &["tool.exe", "helper.bat"]);
let paths = vec![d1.to_string_lossy().to_string()];
let groups = scan_tools(paths, String::new()).unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0].exists);
assert!(groups[0].exes.contains(&"helper.bat".to_string()));
assert!(groups[0].exes.contains(&"tool.exe".to_string()));
}
#[test]
fn scan_tools_with_query_filters() {
let d1 = make_temp_dir_with_exes("t_q", &["apple.exe", "banana.exe"]);
let paths = vec![d1.to_string_lossy().to_string()];
let groups = scan_tools(paths, "apple".into()).unwrap();
assert_eq!(groups[0].exes.len(), 1);
assert_eq!(groups[0].exes[0], "apple.exe");
}
}
+19 -4
View File
@@ -1,7 +1,12 @@
use winreg::enums::*;
use winreg::RegKey;
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
/// 检测当前进程是否有管理员权限
///
/// 通过尝试以写入权限打开系统 PATH 注册表键判断。
///
/// # Returns
/// `true` 表示有管理员权限,`false` 为只读模式
pub fn check_admin() -> bool {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey_with_flags(
@@ -13,7 +18,6 @@ pub fn check_admin() -> bool {
/// 验证路径是否存在于文件系统中(且是目录)
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
pub fn validate_path(path: &str) -> bool {
if path.contains('%') {
return true;
@@ -56,12 +60,23 @@ pub fn expand_env_vars(path: &str) -> String {
return path.to_string();
}
// 转回 UTF-8 (去掉结尾 null)
// 转回 UTF-8 (去掉结尾 null),保留非法码点避免丢失路径信息
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
String::from_utf16_lossy(&buffer[..len])
decode_utf16_preserving(&buffer[..len])
}
/// 解码 UTF-16 为 String,非法码点编码为 \u{XXXX} 而非静默丢弃
fn decode_utf16_preserving(v: &[u16]) -> String {
char::decode_utf16(v.iter().copied())
.map(|r| match r {
Ok(c) => c.to_string(),
Err(e) => format!("\\u{{{:X}}}", e.unpaired_surrogate()),
})
.collect()
}
/// 广播环境变量更改通知(WM_SETTINGCHANGE
/// 广播 `WM_SETTINGCHANGE` 通知系统环境变量已变更
pub fn broadcast_env_change() {
const HWND_BROADCAST: isize = 0xFFFF;
const WM_SETTINGCHANGE: u32 = 0x001A;
+7
View File
@@ -16,6 +16,13 @@ export function createIpcMock() {
case 'expand_env_vars': return 'C:\\\\Expanded';
case 'read_text_file': return '';
case 'get_appdata_dir': return 'C:\\\\appdata';
case 'scan_conflicts': return [];
case 'scan_tools': return [];
case 'list_profiles': return [];
case 'save_profile': return undefined;
case 'load_profile': return null;
case 'delete_profile': return undefined;
case 'rename_profile': return undefined;
default: throw new Error('Unexpected invoke: ' + cmd);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
await page.waitForTimeout(500);
});
test('打开分析对话框查看冲突和工具', async ({ page }) => {
// 点击分析按钮
await page.click('text=分析');
await page.waitForTimeout(500);
// 应显示冲突和工具两个标签
await expect(page.locator('text=冲突检测')).toBeVisible();
await expect(page.locator('text=工具清单')).toBeVisible();
});
+16
View File
@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
await page.waitForTimeout(500);
});
test('导出按钮可见', async ({ page }) => {
await expect(page.locator('text=导出')).toBeVisible();
});
test('导入按钮可见', async ({ page }) => {
await expect(page.locator('text=导入')).toBeVisible();
});
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
await page.waitForTimeout(500);
});
test('Ctrl+N 打开新建对话框', async ({ page }) => {
await page.keyboard.press('Control+n');
await page.waitForTimeout(300);
await expect(page.locator('.fixed.inset-0 input[type="text"]')).toBeVisible();
});
test('Ctrl+F 聚焦搜索框', async ({ page }) => {
await page.keyboard.press('Control+f');
const searchInput = page.locator('input[placeholder]');
await expect(searchInput).toBeFocused();
});
test('F1 打开帮助', async ({ page }) => {
await page.keyboard.press('F1');
await page.waitForTimeout(300);
await expect(page.locator('text=快捷键')).toBeVisible();
});
test('Delete 删除选中行', async ({ page }) => {
// 先选中第一行
await page.locator('table tbody tr').first().click();
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
// 应有 1 行被删除 (原 2 行剩 1 行)
await expect(page.locator('table tbody tr')).toHaveCount(1);
});
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
await page.waitForTimeout(500);
});
test('打开配置管理对话框', async ({ page }) => {
await page.click('text=配置');
await page.waitForTimeout(500);
await expect(page.locator('text=保存当前配置')).toBeVisible();
});
+760 -2
View File
@@ -1,12 +1,12 @@
{
"name": "patheditor",
"version": "4.0.0",
"version": "5.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patheditor",
"version": "4.0.0",
"version": "5.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/api": "^2.11.0",
@@ -23,6 +23,8 @@
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -31,12 +33,71 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz",
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -286,6 +347,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
@@ -445,6 +659,24 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz",
@@ -1347,6 +1579,82 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -1357,6 +1665,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
@@ -1848,6 +2164,41 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1881,6 +2232,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -1981,6 +2342,27 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1988,6 +2370,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
@@ -2006,6 +2402,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
@@ -2013,6 +2416,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2022,6 +2435,14 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.361",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
@@ -2042,6 +2463,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -2436,6 +2870,19 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -2502,6 +2949,16 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2525,6 +2982,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@@ -2548,6 +3012,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
@@ -2894,6 +3409,17 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -2903,6 +3429,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
@@ -3022,6 +3565,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
@@ -3152,6 +3708,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
@@ -3210,6 +3782,38 @@
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.2.tgz",
@@ -3243,6 +3847,19 @@
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
@@ -3312,6 +3929,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -3374,6 +4011,52 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.0",
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.0.tgz",
"integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.0"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.1",
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.1.tgz",
"integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3445,6 +4128,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici": {
"version": "7.26.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.26.0.tgz",
"integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
@@ -3678,6 +4371,54 @@
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
@@ -3721,6 +4462,23 @@
"node": ">=0.10.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+3
View File
@@ -28,6 +28,8 @@
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -36,6 +38,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
+2 -1
View File
@@ -1,5 +1,6 @@
import { useAppStore } from '@/store/app-store';
import { useTranslation } from 'react-i18next';
import { version } from '../../../package.json';
export function TitleBar() {
const { t } = useTranslation();
@@ -13,7 +14,7 @@ export function TitleBar() {
<h1 className="text-lg font-semibold">
{isAdmin ? t('app.name') : t('app.nameReadonly')}
</h1>
<span className="text-sm opacity-60">v4.0</span>
<span className="text-sm opacity-60">v{version}</span>
</header>
);
}
+38 -21
View File
@@ -1,7 +1,10 @@
/**
* 导入导出模块 — 对应 C 版 import_export.c
* 支持 JSON、CSV、TXT 三种格式
* 导入导出模块 — 支持 JSON、CSV、TXT 三种格式
*
* 注意:Rust 端 core/src/fs.rs 有对应的导入导出实现,
* 前端使用此模块(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
*/
import { version } from '../../package.json';
import type { PathEntry } from './path-entry';
export type ExportFormat = 'json' | 'csv' | 'txt';
@@ -23,11 +26,10 @@ export function detectExportFormat(filepath: string): ExportFormat {
export function exportToJson(data: ExportData): string {
const obj = {
version: '1.0',
type: 'PathEditor',
exported: new Date().toISOString(),
system: data.system.map(e => e.path),
user: data.user.map(e => e.path),
version,
timestamp: new Date().toISOString(),
system: data.system.map(e => ({ path: e.path, enabled: e.enabled })),
user: data.user.map(e => ({ path: e.path, enabled: e.enabled })),
};
return JSON.stringify(obj, null, 2);
}
@@ -37,13 +39,13 @@ export function exportToJson(data: ExportData): string {
export function exportToCsv(data: ExportData): string {
const lines: string[] = [];
// UTF-8 BOM
lines.push('type,path');
lines.push('type,path,enabled');
for (const entry of data.system) {
lines.push(`system,${escapeCsvField(entry.path)}`);
lines.push(`system,${escapeCsvField(entry.path)},${entry.enabled}`);
}
for (const entry of data.user) {
lines.push(`user,${escapeCsvField(entry.path)}`);
lines.push(`user,${escapeCsvField(entry.path)},${entry.enabled}`);
}
return lines.join('\n') + '\n';
@@ -92,10 +94,13 @@ export function importFromCsv(content: string): ImportResult {
if (path.length === 0) continue;
// 第三列 enabled(可选,默认 true
const enabled = fields.length >= 3 ? fields[2].trim().toLowerCase() !== 'false' : true;
if (type === 'system') {
result.system.push({ path, enabled: true });
result.system.push({ path, enabled });
} else if (type === 'user') {
result.user.push({ path, enabled: true });
result.user.push({ path, enabled });
}
// 未知类型忽略
}
@@ -153,20 +158,31 @@ export function importFromJson(content: string): ImportResult {
try {
obj = JSON.parse(content);
} catch {
return result; // 无效 JSON 返回空结果,由调用方显示错误
return result;
}
if (typeof obj !== 'object' || obj === null) return result;
const parseEntry = (item: unknown): { path: string; enabled: boolean } | null => {
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed.length > 0 ? { path: trimmed, enabled: true } : null;
}
if (typeof item === 'object' && item !== null) {
const rec = item as Record<string, unknown>;
const path = typeof rec.path === 'string' ? rec.path.trim() : '';
if (path.length === 0) return null;
const enabled = typeof rec.enabled === 'boolean' ? rec.enabled : true;
return { path, enabled };
}
return null;
};
if (Array.isArray(obj.system)) {
result.system = obj.system
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
.map((p: string) => ({ path: p.trim(), enabled: true }));
result.system = obj.system.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
}
if (Array.isArray(obj.user)) {
result.user = obj.user
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
.map((p: string) => ({ path: p.trim(), enabled: true }));
result.user = obj.user.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
}
return result;
@@ -203,9 +219,10 @@ export function importFromContent(
return importFromCsv(content);
} else if (lower.endsWith('.json')) {
return importFromJson(content);
} else {
// TXT 文件:所有路径放入 system(用户后续可选择目标)
} else if (lower.endsWith('.txt')) {
return { system: importFromTxt(content), user: [] };
} else {
throw new Error(`不支持的导入格式: ${filepath}`);
}
}
+1 -1
View File
@@ -98,6 +98,6 @@
"deleted": "Profile \"{{name}}\" deleted"
},
"help": {
"content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
"content": "PathEditor v5.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
}
}
+1 -1
View File
@@ -98,6 +98,6 @@
"deleted": "已删除配置 \"{{name}}\""
},
"help": {
"content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
"content": "PathEditor v5.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
}
}
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { AnalyzeDialog } from '../../src/components/dialogs/AnalyzeDialog';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn((cmd: string) => {
if (cmd === 'scan_conflicts') return Promise.resolve([]);
if (cmd === 'scan_tools') return Promise.resolve([]);
return Promise.resolve(undefined);
}),
}));
vi.mock('@/store/app-store', () => ({
useAppStore: Object.assign(
vi.fn((selector) => {
const state = { sysPaths: [], userPaths: [] };
return selector(state);
}),
{ getState: () => ({ sysPaths: [], userPaths: [] }) },
),
}));
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string) => key) },
}));
describe('AnalyzeDialog', () => {
it('渲染冲突检测和工具清单标签页,不崩溃', () => {
const { container } = render(
<AnalyzeDialog open={true} onClose={() => {}} />,
);
const text = container.textContent || '';
expect(text).toContain('analyze.conflicts');
expect(text).toContain('analyze.tools');
});
});
+11 -10
View File
@@ -24,11 +24,12 @@ describe('exportToJson', () => {
it('导出结构化 JSON', () => {
const json = exportToJson(sampleData);
const parsed = JSON.parse(json);
expect(parsed.version).toBe('1.0');
expect(parsed.type).toBe('PathEditor');
expect(parsed.system).toEqual(sampleData.system.map(e => e.path));
expect(parsed.user).toEqual(sampleData.user.map(e => e.path));
expect(parsed.exported).toBeDefined();
expect(parsed.version).toBe('5.0.0');
expect(parsed.timestamp).toBeDefined();
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(sampleData.system.map(e => e.path));
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(sampleData.user.map(e => e.path));
expect(parsed.system[0].enabled).toBe(true);
expect(parsed.user[0].enabled).toBe(true);
});
});
@@ -54,21 +55,21 @@ describe('exportToCsv', () => {
it('导出 CSV 含 BOM', () => {
const csv = exportToCsv(sampleData);
expect(csv.startsWith('')).toBe(true);
expect(csv).toContain('type,path');
expect(csv).toContain('system,C:\\Windows');
expect(csv).toContain('user,C:\\Users\\me\\AppData');
expect(csv).toContain('type,path,enabled');
expect(csv).toContain('system,C:\\Windows,true');
expect(csv).toContain('user,C:\\Users\\me\\AppData,true');
});
it('CSV 字段转义', () => {
const data = { system: [pe('C:\\Path,with,commas')], user: [] };
const csv = exportToCsv(data);
expect(csv).toContain('"C:\\Path,with,commas"');
expect(csv).toContain('"C:\\Path,with,commas",true');
});
it('CSV 双引号转义', () => {
const data = { system: [pe('Path with "quotes"')], user: [] };
const csv = exportToCsv(data);
expect(csv).toContain('"Path with ""quotes"""');
expect(csv).toContain('"Path with ""quotes""",true');
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { importFromCsv, importFromJson, importFromTxt } from '../../src/core/import-export';
describe('导入一致性(TS 端)', () => {
it('JSON 含 system + user', () => {
const json = JSON.stringify({ system: ['C:\\a', 'C:\\b'], user: ['D:\\c'] });
const r = importFromJson(json);
expect(r.system.map(e => e.path)).toEqual(['C:\\a', 'C:\\b']);
expect(r.user.map(e => e.path)).toEqual(['D:\\c']);
});
it('CSV system/user 分类', () => {
const csv = 'type,path\nsystem,C:\\sys\nuser,D:\\usr\n';
const r = importFromCsv(csv);
expect(r.system.map(e => e.path)).toEqual(['C:\\sys']);
expect(r.user.map(e => e.path)).toEqual(['D:\\usr']);
});
it('CSV 含 BOM + header', () => {
const csv = 'type,path\nsystem,C:\\x\n';
const r = importFromCsv(csv);
expect(r.system.map(e => e.path)).toEqual(['C:\\x']);
});
it('TXT 逐行读取,跳过注释', () => {
const txt = '# comment\nC:\\a\n\nD:\\b\n';
const r = importFromTxt(txt);
expect(r.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
});
it('JSON 空数据不崩溃', () => {
const r = importFromJson('{}');
expect(r.system).toEqual([]);
expect(r.user).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { MergePreview } from '../../src/components/path-list/MergePreview';
vi.mock('@/store/app-store', () => ({
useAppStore: vi.fn((selector) => {
const state = {
sysPaths: [
{ path: 'C:\\Windows', enabled: true },
{ path: 'C:\\Disabled', enabled: false },
],
userPaths: [
{ path: 'D:\\UserApp', enabled: true },
],
searchQuery: '',
};
return selector(state);
}),
}));
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string) => key) },
}));
describe('MergePreview', () => {
it('合并显示系统+用户路径', () => {
const { container } = render(<MergePreview />);
const text = container.textContent || '';
expect(text).toContain('C:\\Windows');
expect(text).toContain('D:\\UserApp');
});
it('disabled 路径在表格中存在', () => {
const { container } = render(<MergePreview />);
const text = container.textContent || '';
expect(text).toContain('C:\\Disabled');
});
});