From 21af2683ac4313c35f8fe8573bb658118ee50fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Fri, 29 May 2026 01:13:21 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=85=A8=E9=9D=A2=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20+=20=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E6=A0=87=E9=85=8D=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 审查修复 (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 --- .github/PULL_REQUEST_TEMPLATE.md | 28 ++ .github/workflows/ci.yml | 3 - .gitignore | 1 + CHANGELOG.md | 109 ++--- CODE_OF_CONDUCT.md | 36 ++ CONTRIBUTING.md | 83 +++- Cargo.lock | 1 - SECURITY.md | 37 ++ cli/src/main.rs | 60 ++- core/src/backup.rs | 38 +- core/src/disabled.rs | 32 +- core/src/fs.rs | 132 ++++- core/src/profiles.rs | 97 +++- core/src/registry.rs | 20 + core/src/scanner.rs | 166 +++++-- core/src/system.rs | 23 +- e2e/mocks/ipc.ts | 7 + e2e/tests/analyze.spec.ts | 18 + e2e/tests/import-export.spec.ts | 16 + e2e/tests/keyboard.spec.ts | 35 ++ e2e/tests/profiles.spec.ts | 14 + package-lock.json | 762 ++++++++++++++++++++++++++++- package.json | 3 + src/components/layout/TitleBar.tsx | 3 +- src/core/import-export.ts | 59 ++- src/i18n/locales/en.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- tests/unit/analyze-dialog.test.tsx | 37 ++ tests/unit/import-export.test.ts | 21 +- tests/unit/import-parity.test.ts | 36 ++ tests/unit/merge-preview.test.tsx | 39 ++ 31 files changed, 1702 insertions(+), 218 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md create mode 100644 e2e/tests/analyze.spec.ts create mode 100644 e2e/tests/import-export.spec.ts create mode 100644 e2e/tests/keyboard.spec.ts create mode 100644 e2e/tests/profiles.spec.ts create mode 100644 tests/unit/analyze-dialog.test.tsx create mode 100644 tests/unit/import-parity.test.ts create mode 100644 tests/unit/merge-preview.test.tsx diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..49d260f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## 改动描述 + + + +## 关联 Issue + + + +## 改动类型 + +- [ ] 新功能 (feat) +- [ ] Bug 修复 (fix) +- [ ] 重构 (refactor) +- [ ] 文档 (docs) +- [ ] 测试 (test) +- [ ] 构建/CI (chore) + +## 测试计划 + +- [ ] `cargo clippy -- -D warnings` 零警告 +- [ ] `cargo test` 全部通过 +- [ ] `npm test` 全部通过 +- [ ] `npx tsc -b --noEmit` 零错误 +- [ ] E2E 测试 (如有 UI 变更) + +## 截图 + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f3abdc..c90e281 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9ab6f95..792a220 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ dist-ssr CLAUDE.md e2e/debug-screenshot.png test-results/ +target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd04d7b..279e6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 UI(React 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 +- 撤销/重做支持 +- 保存前自动备份注册表 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..14d1247 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,36 @@ +# 贡献者行为准则 + +## 我们的承诺 + +为了营造一个开放和友好的环境,我们作为贡献者和维护者承诺:无论年龄、体型、残障、种族、性别认同和表达、经验水平、国籍、个人外貌、宗教、性取向或身份,参与本项目不会受到骚扰。 + +## 我们的标准 + +有助于创造积极环境的行为包括: + +- 使用友好和包容的语言 +- 尊重不同的观点和经验 +- 优雅地接受建设性批评 +- 关注对社区最有利的事情 +- 对其他社区成员表示同理心 + +不可接受的行为包括: + +- 使用性暗示语言或图像以及不受欢迎的性关注 +- 侮辱/贬损性评论以及人身攻击或政治攻击 +- 公开或私下的骚扰 +- 未经明确许可发布他人的私人信息 + +## 我们的责任 + +项目维护者有责任澄清可接受行为的标准,并应对任何不可接受的行为采取适当和公平的纠正措施。 + +## 范围 + +本行为准则适用于项目空间和代表项目的公共空间。 + +## 执行 + +可通过 GitHub Issues 或直接联系维护者报告辱骂、骚扰或其他不可接受的行为。所有投诉将被审查和调查,并将产生被认为必要且适合情况的回应。 + +本项目改编自 [Contributor Covenant](https://www.contributor-covenant.org) 2.1 版。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae96961..0254767 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 警告 diff --git a/Cargo.lock b/Cargo.lock index 43569a7..1df6683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2396,7 +2396,6 @@ dependencies = [ "log", "path-editor-core", "serde", - "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..54da204 --- /dev/null +++ b/SECURITY.md @@ -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/` 目录设为仅当前用户可读。 diff --git a/cli/src/main.rs b/cli/src/main.rs index 556d35f..8964db1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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) -> Vec) { fn cmd_list(system: bool, user: bool, json_out: bool) { let mut sys: Vec = vec![]; let mut usr: Vec = 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) { 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 = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect(); - let usr: Vec = 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 = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect(); + let new_usr: Vec = 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), }, } } diff --git a/core/src/backup.rs b/core/src/backup.rs index 20c6ad3..35fdc00 100644 --- a/core/src/backup.rs +++ b/core/src/backup.rs @@ -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) -> Result { 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) -> Result { 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); + } +} diff --git a/core/src/disabled.rs b/core/src/disabled.rs index bd6d144..fa6b1bb 100644 --- a/core/src/disabled.rs +++ b/core/src/disabled.rs @@ -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, Vec), 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()); + } +} diff --git a/core/src/fs.rs b/core/src/fs.rs index 4be763f..cb0f8a7 100644 --- a/core/src/fs.rs +++ b/core/src/fs.rs @@ -1,3 +1,6 @@ +// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现, +// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。 + /// 读取文本文件内容(供前端原生对话框选择文件后使用) pub fn read_text_file(path: &str) -> Result { std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e)) @@ -35,9 +38,26 @@ fn import_json(content: &str) -> Result<(Vec, Vec), String> { fn import_csv(content: &str) -> Result<(Vec, Vec), String> { let mut sys = Vec::new(); let mut usr = Vec::new(); + let mut first = true; 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, Vec), String> { } /// 导出 PATH 为指定格式字符串 -pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> String { +pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result { 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 = 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"]); } } diff --git a/core/src/profiles.rs b/core/src/profiles.rs index c76ab3b..3e6e1c8 100644 --- a/core/src/profiles.rs +++ b/core/src/profiles.rs @@ -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, 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::(&content) { profiles.push(ProfileMeta { name: data.name, @@ -72,10 +87,11 @@ pub fn save_profile( sys: Vec, user: Vec, ) -> 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 { - 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 { /// 删除配置文件 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 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(); + } +} diff --git a/core/src/registry.rs b/core/src/registry.rs index bd0c947..d517cac 100644 --- a/core/src/registry.rs +++ b/core/src/registry.rs @@ -44,21 +44,41 @@ fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) } +/// 从 HKLM 注册表读取系统 PATH +/// +/// # Returns +/// - `Ok(Vec)` — 系统 PATH 路径列表 +/// - `Err(String)` — 注册表读取失败 pub fn load_system_paths() -> Result, String> { load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统") } +/// 从 HKCU 注册表读取用户 PATH +/// +/// # Returns +/// - `Ok(Vec)` — 用户 PATH 路径列表 +/// - `Err(String)` — 注册表读取失败 pub fn load_user_paths() -> Result, String> { load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户") } +/// 保存系统 PATH 到注册表,含 32767 字符上限检查 +/// +/// # Returns +/// - `Ok(())` — 保存成功 +/// - `Err(String)` — 写入失败或超过字符上限 pub fn save_system_paths(paths: Vec) -> Result<(), String> { save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths) } +/// 保存用户 PATH 到注册表 +/// +/// # Returns +/// - `Ok(())` — 保存成功 +/// - `Err(String)` — 写入失败 pub fn save_user_paths(paths: Vec) -> Result<(), String> { save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths) } diff --git a/core/src/scanner.rs b/core/src/scanner.rs index ebb3c1c..2b2feb4 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -23,33 +23,50 @@ pub struct ToolGroup { pub exes: Vec, } -/// 扫描 PATH 中的可执行文件冲突 -/// -/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件, -/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」) - -pub fn scan_conflicts(paths: Vec) -> Result, String> { - // exe_name (小写) → [(priority, dir)] - let mut map: HashMap> = 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 { + let p = Path::new(dir); + if !p.is_dir() { + return vec![]; + } + let mut exes: Vec = 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) -> Result, String> { + // 并行扫描各目录 + let results: Vec<(usize, String, Vec)> = 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> = 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 = map .into_iter() @@ -69,45 +86,96 @@ pub fn scan_conflicts(paths: Vec) -> Result, String> /// 扫描 PATH 中各目录提供的可执行文件 /// -/// query 非空时只返回文件名包含关键词的结果 +/// query 非空时只返回文件名包含关键词的结果。各目录并行扫描。 pub fn scan_tools(paths: Vec, query: String) -> Result, String> { let query_lower = query.to_lowercase(); - let mut groups: Vec = 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 = 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>)> = 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 = 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"); + } +} diff --git a/core/src/system.rs b/core/src/system.rs index 4c41cb1..c4faf48 100644 --- a/core/src/system.rs +++ b/core/src/system.rs @@ -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; diff --git a/e2e/mocks/ipc.ts b/e2e/mocks/ipc.ts index 1a20c58..00acdbd 100644 --- a/e2e/mocks/ipc.ts +++ b/e2e/mocks/ipc.ts @@ -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); } } diff --git a/e2e/tests/analyze.spec.ts b/e2e/tests/analyze.spec.ts new file mode 100644 index 0000000..bfef8a5 --- /dev/null +++ b/e2e/tests/analyze.spec.ts @@ -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(); +}); diff --git a/e2e/tests/import-export.spec.ts b/e2e/tests/import-export.spec.ts new file mode 100644 index 0000000..6327f68 --- /dev/null +++ b/e2e/tests/import-export.spec.ts @@ -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(); +}); diff --git a/e2e/tests/keyboard.spec.ts b/e2e/tests/keyboard.spec.ts new file mode 100644 index 0000000..5e2a6ac --- /dev/null +++ b/e2e/tests/keyboard.spec.ts @@ -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); +}); diff --git a/e2e/tests/profiles.spec.ts b/e2e/tests/profiles.spec.ts new file mode 100644 index 0000000..7af2fb0 --- /dev/null +++ b/e2e/tests/profiles.spec.ts @@ -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(); +}); diff --git a/package-lock.json b/package-lock.json index 47672a4..2174051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1740aa4..790477d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 0ad0a04..5a3f1dc 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -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() {

{isAdmin ? t('app.name') : t('app.nameReadonly')}

- v4.0 + v{version} ); } diff --git a/src/core/import-export.ts b/src/core/import-export.ts index ed0a102..aa5fd1c 100644 --- a/src/core/import-export.ts +++ b/src/core/import-export.ts @@ -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; + 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}`); } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2fc9d99..ab042e3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fcf29b0..a057864 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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" } } diff --git a/tests/unit/analyze-dialog.test.tsx b/tests/unit/analyze-dialog.test.tsx new file mode 100644 index 0000000..89e6138 --- /dev/null +++ b/tests/unit/analyze-dialog.test.tsx @@ -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( + {}} />, + ); + const text = container.textContent || ''; + expect(text).toContain('analyze.conflicts'); + expect(text).toContain('analyze.tools'); + }); +}); diff --git a/tests/unit/import-export.test.ts b/tests/unit/import-export.test.ts index 1dd3d74..4c96a28 100644 --- a/tests/unit/import-export.test.ts +++ b/tests/unit/import-export.test.ts @@ -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'); }); }); diff --git a/tests/unit/import-parity.test.ts b/tests/unit/import-parity.test.ts new file mode 100644 index 0000000..1d26945 --- /dev/null +++ b/tests/unit/import-parity.test.ts @@ -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([]); + }); +}); diff --git a/tests/unit/merge-preview.test.tsx b/tests/unit/merge-preview.test.tsx new file mode 100644 index 0000000..ec5ab71 --- /dev/null +++ b/tests/unit/merge-preview.test.tsx @@ -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(); + const text = container.textContent || ''; + expect(text).toContain('C:\\Windows'); + expect(text).toContain('D:\\UserApp'); + }); + + it('disabled 路径在表格中存在', () => { + const { container } = render(); + const text = container.textContent || ''; + expect(text).toContain('C:\\Disabled'); + }); +});