Compare commits

...

15 Commits

Author SHA1 Message Date
Serendipity 44a4a4ccf3 docs: CLI 命令从 17 增到 18,profile rename 补入参考文档
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
Release / 构建 NSIS 安装包并发布 (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:27:22 +08:00
Serendipity dc36d63302 refactor: CLI 二进制名从 patheditor-cli 改为 patheditor
添加 [[bin]] name = "patheditor",安装后即可直接使用 patheditor 命令。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:21:18 +08:00
Serendipity 6822ab9f3e docs: 架构图补充 CLI 流程 — 总览图加 clap/原子性 + 新增 CLI 操作时序图
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 08:17:35 +08:00
Serendipity 21af2683ac 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>
2026-05-29 01:13:21 +08:00
Serendipity 7aa5dcd832 docs: 更新 README 反映 v5.0 workspace 架构 + CLI 命令行
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
- 项目结构改为 core / gui / cli 三 crate
- 新增 CLI 命令行使用说明
- 更新构建命令和技术栈
- 修正安装包名称

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:59:14 +08:00
Serendipity 9b5b57a3ac fix: CLI 写操作加原子性保护 — 保存前重新读取注册表对比
所有修改命令(add/remove/edit/move/clean/import)在保存前重新读取
注册表,与操作前加载的值对比,不一致则报错退出,防止覆盖其他进程的修改。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:56:50 +08:00
Serendipity 1320aa57a8 fix: 架构审查修复 — broadcast、目标校验、import_csv 警告、workspace 元数据统一
- HIGH: CLI 所有修改命令补 broadcast_env_change()
- HIGH: --system/--user 互斥校验,不再静默忽略
- MEDIUM: gui/Cargo.toml 删冗余 serde_json(log 保留,lib.rs 实际使用)
- MEDIUM: import_csv 对跳过行输出 log::warn
- MEDIUM: ProfilePathEntry 从 core 重导出
- LOW: Cargo workspace.package 统一元数据

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:52:31 +08:00
Serendipity a553a16a64 feat: CLI 补全至 GUI 功能 100% 对等 — 新增 9 条命令
新增: edit, move-up, move-down, clean, enable, disable, import, export, backup
core: registry.rs +clean_paths, fs.rs +import_paths +export_paths
CLI 特有增强: move-up/move-down 支持 --steps N 一次移动多格

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:43:05 +08:00
Serendipity c181fe15d4 fix: 删除残留的 src-tauri 目录(已重命名为 gui)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:30:22 +08:00
Serendipity 36e1c89b2e fix: 审查修复 — save_profile 保留原始 created、&str 参数、clippy 清理
- CRITICAL: save_profile 覆盖已有配置时保留原始创建时间
- HIGH: profiles.rs 函数参数 String → &str(减少不必要的克隆)
- MEDIUM: 修复 18 个 clippy警告(空行 + map_or + collapsible-if)
- CLI: 移除不必要的 name.clone() 调用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:26:54 +08:00
Serendipity 812f39b159 refactor: src-tauri 重命名为 gui
Tauri CLI 自动检测 workspace 成员,构建不受影响。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:19:14 +08:00
Serendipity cd896d389b refactor: 提取 core 库 + 新增 CLI 版本
- 创建 Cargo workspace(core / src-tauri / cli 三 crate)
- core: 纯 Rust 库,零 Tauri 依赖,包含所有业务逻辑
- src-tauri/commands: 改为薄包装,调用 core 函数
- cli: 基于 clap 的命令行工具,支持 JSON 输出
- CLI 命令: list, add, remove, conflicts, scan, profile, check-admin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:13:28 +08:00
Serendipity 5a864c41b2 chore: 版本号统一升级至 5.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:46:14 +08:00
Serendipity 986fe7f0d9 docs: README 用 Mermaid 流程图替换截图占位符 — 架构图、组件树、操作流程
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:54:53 +08:00
Serendipity 9c74c61d64 chore: 版本号统一升级至 4.3.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:35:45 +08:00
77 changed files with 8257 additions and 461 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: src-tauri
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: 上传安装包到 Release
run: |
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
$installer = Get-ChildItem -Path "target\release\bundle\nsis\*.exe" | Select-Object -First 1
if (gh release view $env:GITHUB_REF_NAME 2>$null) {
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
} else {
+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
- 撤销/重做支持
- 保存前自动备份注册表
+171
View File
@@ -0,0 +1,171 @@
# CLAUDE.md
## 项目概述
PathEditor v5.0 — Windows 系统环境变量 (PATH) 编辑器,Tauri 2.x + React 19 + TypeScript + Rust workspace 构建。GUI + CLI 双模式。
## 构建命令
```bash
# 安装前端依赖
npm install
# 开发模式(GUI 热更新)
npx tauri dev
# 仅前端(浏览器预览,无注册表功能)
npm run dev
# 前端测试
npm test
npm run test:watch
# Rust workspace 全部检查
cargo check
# 仅核心库检查
cargo check -p path-editor-core
# CLI 构建
cargo build --release -p patheditor-cli
# 生产构建(前端)
npm run build
# 完整构建(生成 NSIS 安装包)
npx tauri build
```
## 架构
Cargo workspace 三层,前后端分离,通过 Tauri IPC 通信。
```
PathEditor/
├── core/ # Rust 库 crate(零 Tauri 依赖)
│ └── src/
│ ├── registry.rs # 注册表读写 + clean_paths
│ ├── system.rs # check_admin、validate_path、expand_env_vars、broadcast
│ ├── backup.rs # backup_registry、get_appdata_dir
│ ├── disabled.rs # save/load_disabled_state
│ ├── fs.rs # read_text_file、import_paths、export_paths
│ ├── scanner.rs # scan_conflicts、scan_tools
│ ├── profiles.rs # list/save/load/delete/rename_profile
│ └── lib.rs
├── gui/ # Tauri 桌面应用(依赖 core
│ └── src/commands/ # 薄包装:#[tauri::command] → 调用 core
├── cli/ # CLI 命令行(依赖 core + clap
│ └── src/main.rs # 18 条命令
├── src/ # React 前端 (TypeScript strict 模式)
│ ├── core/ # 纯逻辑 — 零 React/零 Tauri 依赖
│ ├── store/ # Zustand 状态管理
│ ├── components/ # UI 组件
│ │ ├── layout/ # AppShell、TitleBar、StatusBar、ErrorBoundary
│ │ ├── path-list/ # PathTable、MergePreview
│ │ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons
│ │ ├── dialogs/ # PathEdit、Help、Import、Analyze、Profile
│ │ └── ui/ # Modal、buttons
│ ├── hooks/ # useAppActions、useKeyboard
│ ├── i18n/ # zh-CN / en
│ └── config/ # default.json
├── tests/unit/ # Vitest 前端单元测试
├── e2e/ # Playwright E2E 测试
└── Cargo.toml # Workspace 根 + [workspace.package]
```
## IPC 接口(Rust → Frontend
| Command | 参数 | 返回值 | 功能 |
|---------|------|--------|------|
| `load_system_paths` | — | `Result<Vec<String>, String>` | 从 HKLM 读取系统 PATH |
| `load_user_paths` | — | `Result<Vec<String>, String>` | 从 HKCU 读取用户 PATH |
| `save_system_paths` | `paths: Vec<String>` | `Result<(), String>` | 保存系统 PATH(含 32767 字符上限) |
| `save_user_paths` | `paths: Vec<String>` | `Result<(), String>` | 保存用户 PATH |
| `check_admin` | — | `bool` | 检测管理员权限 |
| `validate_path` | `path: &str` | `bool` | 检查目录是否存在 |
| `expand_env_vars` | `path: &str` | `String` | 展开 `%VAR%` |
| `broadcast_env_change` | — | `()` | 广播 `WM_SETTINGCHANGE` |
| `backup_registry` | `custom_dir` | `Result<String, String>` | 备份注册表 |
| `get_appdata_dir` | — | `String` | 备份目录路径 |
| `save_disabled_state` | `system, user` | `Result<(), String>` | 持久化禁用状态 |
| `load_disabled_state` | — | `Result<(Vec, Vec), String>` | 加载禁用状态 |
| `read_text_file` | `path` | `Result<String, String>` | 读取文本文件 |
| `scan_conflicts` | `paths` | `Result<Vec<ConflictEntry>, String>` | 可执行文件冲突检测 |
| `scan_tools` | `paths, query` | `Result<Vec<ToolGroup>, String>` | 可执行文件清单 |
| `list_profiles` | — | `Result<Vec<ProfileMeta>, String>` | 列出配置 |
| `save_profile` | `name, sys, user` | `Result<(), String>` | 保存配置 |
| `load_profile` | `name` | `Result<ProfileData, String>` | 加载配置 |
| `delete_profile` | `name` | `Result<(), String>` | 删除配置 |
| `rename_profile` | `old, new` | `Result<(), String>` | 重命名配置 |
## CLI 命令参考
```
patheditor list [--system|--user] [--json]
patheditor add <PATH> [--system|--user]
patheditor remove <INDEX> [--system|--user]
patheditor edit <INDEX> <NEW> [--system|--user]
patheditor move-up <INDEX> [--steps N] [--system|--user]
patheditor move-down <INDEX> [--steps N] [--system|--user]
patheditor clean [--system|--user] [--dry-run] [--json]
patheditor enable <INDEX> [--system|--user]
patheditor disable <INDEX> [--system|--user]
patheditor import <FILE> [--target system|user|both]
patheditor export [--format json|csv|txt] [--output <FILE>]
patheditor backup
patheditor conflicts [--json]
patheditor scan [--query <NAME>] [--json]
patheditor check-admin [--json]
patheditor profile {list|save|load|apply|delete|rename}
```
所有修改操作在保存前重新读取注册表验证,防止覆盖其他进程的修改。
## 错误处理
### 前端
| 场景 | 处理 |
|------|------|
| IPC 调用失败 | `Promise.allSettled` 精确报告哪个 hive 保存失败 |
| JSON 文件损坏 | `importFromJson` try/catch,返回空结果 |
| 保存并发双击 | `isSaving` 守卫,第二次调用直接 return |
| 备份创建失败 | `.catch()` 显示 warning_backup,保存继续 |
| 渲染异常 | ErrorBoundary 捕获 + console.error + 重试按钮 |
### Rust / CLI
- 注册表操作:全部返回 `Result<T, String>`,中文错误消息
- FFI 调用:失败时 `log::warn` + 返回安全回退值
- PATH 长度:写入前检查 32767 字符上限
- SAFETY 注释:所有 `unsafe` 块均有文档
- CLI 原子性:保存前重新读取注册表与加载值对比,不一致报错退出
## 关键约束
- **TypeScript**`strict: true`,零编译错误
- **Rust 工具链**`stable-x86_64-pc-windows-gnu`(项目已设 override
- **MinGW 兼容**`.cargo/config.toml` 添加 `-lmcfgthread`GCC 15.2.0 运行时)
- **运行权限**:需要管理员权限才能编辑系统 PATH,非管理员自动进入只读模式
- **构建产物**NSIS 安装包,约 8MB
## 版本号升级清单
版本号需在 **4 个地方** 手动修改:
| 文件 | 字段 | 说明 |
|------|------|------|
| `Cargo.toml` | `[workspace.package] version` | Rust 全量自动继承(3 crate + `env!("CARGO_PKG_VERSION")` |
| `package.json` | `version` | 前端动态 importTitleBar、import-export.ts 自动读取) |
| `gui/tauri.conf.json` | `version` | 打包版本号 |
| `gui/tauri.conf.json` | 窗口 `title` | 窗口标题栏显示 |
| `README.md` | badges | 文档徽章 |
其他位置均从上述两源头动态读取,无需单独修改:
- `core/src/fs.rs` / `cli/src/main.rs``env!("CARGO_PKG_VERSION")`
- `src/components/layout/TitleBar.tsx``import { version } from '../../../package.json'`
- `src/core/import-export.ts``import { version } from '../../package.json'`
Release 操作:
- `gh release create vX.Y.Z` 创建新 Release
- 安装包上传到新 Release,**不要**覆盖旧版本
+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
+5328
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[workspace]
resolver = "2"
members = [
"core",
"gui",
"cli",
]
[workspace.package]
version = "5.0.0"
edition = "2021"
license = "MIT"
authors = ["刘航宇"]
repository = "https://github.com/LHY0125/PathEditor"
+171 -26
View File
@@ -4,7 +4,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-4.2.0-blue" alt="version">
<img src="https://img.shields.io/badge/version-5.0.0-blue" alt="version">
<img src="https://img.shields.io/badge/tauri-2.x-ffa03a" alt="tauri">
<img src="https://img.shields.io/badge/react-19-61dafb" alt="react">
<img src="https://img.shields.io/badge/rust-1.95-000000" alt="rust">
@@ -20,11 +20,154 @@
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
v4.2 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
v5.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
## 截图
## 架构
_[待补充]_
```mermaid
graph TB
subgraph 前端["React 前端"]
UI[UI 组件层<br/>AppShell / PathTable / Dialogs]
Store[状态管理<br/>Zustand Store]
Core[纯逻辑层<br/>undo-redo / path-manager / validation]
UI --> Store
UI --> Core
Store --> Core
end
subgraph CLI["CLI 命令行"]
Clap[clap 参数解析<br/>18 条命令]
Atomic[原子性保护<br/>verify_and_save]
end
subgraph IPC["Tauri IPC 桥接"]
invoke[invoke / plugin-dialog]
end
subgraph 后端["Rust core 库"]
Registry[注册表读写<br/>HKLM / HKCU]
System[系统操作<br/>权限检测 / 路径验证 / 环境变量展开]
Files[文件操作<br/>备份 / 配置 / 导入导出]
Scanner[分析引擎<br/>冲突检测 / 工具清单]
Profiles[配置管理<br/>save/load/apply/rename]
end
subgraph Windows["Windows 系统"]
Reg[(注册表<br/>SYSTEM / USER PATH)]
FS[(文件系统<br/>目录验证 / exe 扫描)]
end
UI --> invoke
invoke --> Registry
invoke --> System
invoke --> Files
invoke --> Scanner
invoke --> Profiles
Clap --> Atomic
Atomic --> Registry
Atomic --> System
Atomic --> Files
Atomic --> Scanner
Atomic --> Profiles
Registry --> Reg
System --> FS
Scanner --> FS
Files --> FS
Profiles --> FS
```
### 组件树
```mermaid
graph TD
App["App.tsx<br/>ErrorBoundary"]
Shell["AppShell<br/>布局编排 + 弹窗管理"]
TitleBar["TitleBar<br/>拖拽区域"]
ToolBar["ToolBar<br/>搜索 / 操作 / 分析 / 配置"]
PathTable["PathTable<br/>路径列表 + 验证 + 复选框"]
MergePreview["MergePreview<br/>系统+用户合并视图"]
StatusBar["StatusBar<br/>状态 / 权限 / 重试"]
Dialogs["弹窗层<br/>PathEdit / Import / Help / Analyze / Profile"]
App --> Shell
Shell --> TitleBar
Shell --> ToolBar
Shell --> PathTable
Shell --> MergePreview
Shell --> StatusBar
Shell --> Dialogs
```
### 操作流程
```mermaid
sequenceDiagram
actor U as 用户
participant UI as React UI
participant Z as Zustand Store
participant IPC as Tauri IPC
participant R as Rust 后端
participant Win as Windows
U->>UI: 点击「保存」
UI->>Z: savePaths()
Z->>IPC: invoke('backup_registry')
IPC->>R: backup_registry()
R->>Win: 读取注册表 → 写入备份文件
Z->>IPC: Promise.allSettled([save_system, save_user])
IPC->>R: save_system_paths() / save_user_paths()
R->>Win: RegSetValueEx()
Z->>IPC: invoke('broadcast_env_change')
IPC->>R: SendMessageTimeout(WM_SETTINGCHANGE)
R->>Win: 通知所有进程
Z->>UI: isModified → false, statusMessage → '保存成功'
```
### CLI 操作流程
```mermaid
sequenceDiagram
actor U as 用户
participant CLI as patheditor
participant Core as Rust core 库
participant Win as Windows
U->>CLI: patheditor add "D:\Tools" --system
CLI->>Core: load_system_paths() → 旧列表
CLI->>CLI: 执行操作 (push / splice / clean)
CLI->>Core: load_system_paths() → 重新读取
alt 注册表未修改
CLI->>Core: save_system_paths(new_list)
Core->>Win: RegSetValueEx()
CLI->>Core: broadcast_env_change()
Core->>Win: SendMessageTimeout(WM_SETTINGCHANGE)
CLI-->>U: 已添加到系统 PATH
else 注册表已被其他进程修改
CLI-->>U: 错误: 注册表已被其他进程修改
end
```
## CLI 命令行
```bash
# 安装
cargo install --path cli
# 安装后可直接使用:
patheditor --help
# 查看 PATH
patheditor list --system --json
# 冲突检测
patheditor conflicts
# 配置切换
patheditor profile save "Python开发"
patheditor profile apply "Python开发"
```
完整 18 条命令:`patheditor --help`
## 功能
@@ -64,7 +207,7 @@ _[待补充]_
## 安装
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.2.0_x64-setup.exe` 安装。
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_5.0.0_x64-setup.exe` 安装。
或从源码构建:
@@ -81,7 +224,7 @@ npx tauri build
## 开发
```bash
# 开发模式(热更新)
# 开发模式 GUI(热更新)
npx tauri dev
# 仅前端
@@ -90,11 +233,14 @@ npm run dev
# 前端测试
npm test
# Rust 后端检查
cd src-tauri && cargo check
# Rust workspace 检查
cargo check
# Rust 后端测试
cd src-tauri && cargo test
# CLI 构建
cargo build --release -p patheditor-cli
# 完整构建
npx tauri build
```
### 技术栈
@@ -106,34 +252,33 @@ cd src-tauri && cargo test
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 后端 | Rust (winreg + windows-rs FFI) |
| 核心库 | Rust workspace (core + gui + cli) |
| 前端测试 | Vitest (72 个测试) |
| Rust 测试 | cargo test (10 个测试) |
| 构建 | Vite |
| 构建 | Vite + Cargo |
| 打包 | NSIS |
### 项目结构
```
core/ # Rust 核心库(零 Tauri 依赖)
├── registry.rs # 注册表读写 + 路径清理
├── system.rs # 权限检测、路径验证、环境变量展开
├── scanner.rs # 冲突检测、工具清单
├── profiles.rs # 配置文件管理
├── backup.rs / disabled.rs # 备份、禁用状态
└── fs.rs # 文件读写、导入导出解析
gui/ # Tauri 桌面应用
└── src/commands/ # 薄包装 → 调用 core
cli/ # 命令行工具
└── src/main.rs # 18 条命令
src/ # React 前端
├── core/ # 纯逻辑 — 零框架依赖、零平台依赖
├── core/ # 纯逻辑 — 零框架依赖
├── store/ # Zustand 状态管理
├── components/
│ ├── layout/ # AppShell、TitleBar、StatusBar、ErrorBoundary
│ ├── path-list/ # PathTable、MergePreview
│ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons、SearchInput
│ ├── dialogs/ # PathEditDialog、HelpDialog、ImportDialog
│ └── ui/ # Modal、buttons(共享组件)
├── components/ # UI 组件
├── hooks/ # useAppActions、useKeyboard
├── i18n/ # zh-CN / en
└── config/ # default.json
src-tauri/ # Rust 后端
└── src/commands/
├── registry.rs # 注册表读写
├── system.rs # 权限检测、路径验证、环境变量展开
└── backup.rs # 注册表备份
tests/unit/ # 前端单元测试
```
+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/` 目录设为仅当前用户可读。
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "patheditor-cli"
description = "PathEditor CLI — command-line interface for Windows PATH management"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "patheditor"
path = "src/main.rs"
[dependencies]
path-editor-core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
+462
View File
@@ -0,0 +1,462 @@
use clap::{Parser, Subcommand};
use path_editor_core as core;
use serde_json::json;
#[derive(Parser)]
#[command(name = "patheditor", version = env!("CARGO_PKG_VERSION"))]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// 列出 PATH 路径
List {
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
#[arg(long)] json: bool,
},
/// 添加一条路径
Add {
path: String,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 删除指定位置的路径
Remove {
index: usize,
#[arg(short, long)] system: bool,
},
/// 编辑指定位置的路径
Edit {
index: usize,
new_path: String,
#[arg(short, long)] system: bool,
},
/// 上移路径(--steps 指定移动格数,默认 1)
MoveUp {
index: usize,
#[arg(long, default_value = "1")] steps: usize,
#[arg(short, long)] system: bool,
},
/// 下移路径(--steps 指定移动格数,默认 1)
MoveDown {
index: usize,
#[arg(long, default_value = "1")] steps: usize,
#[arg(short, long)] system: bool,
},
/// 清理无效和重复路径
Clean {
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
#[arg(long)] dry_run: bool,
#[arg(long)] json: bool,
},
/// 启用指定位置的路径
Enable {
index: usize,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 禁用指定位置的路径
Disable {
index: usize,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 从文件导入 PATHJSON/CSV/TXT
Import {
file: String,
#[arg(long, default_value = "both")] target: String,
},
/// 导出 PATH 为文件
Export {
#[arg(long, default_value = "json")] format: String,
#[arg(short, long)] output: Option<String>,
},
/// 创建注册表备份
Backup,
/// 检测可执行文件冲突
Conflicts { #[arg(long)] json: bool },
/// 列出 PATH 目录中的可执行文件
Scan {
#[arg(long)] query: Option<String>,
#[arg(long)] json: bool,
},
/// 检查管理员权限
CheckAdmin { #[arg(long)] json: bool },
/// 管理配置文件
#[command(subcommand)]
Profile(ProfileCmd),
}
#[derive(Subcommand)]
enum ProfileCmd {
/// 列出所有配置
List { #[arg(long)] json: bool },
/// 保存当前 PATH 为配置
Save { name: String },
/// 加载配置(预览)
Load { name: String },
/// 应用配置(写入注册表)
Apply { name: String },
/// 删除配置
Delete { name: String },
/// 重命名配置
Rename {
#[arg(long)] old: String,
#[arg(long)] new: String,
},
}
fn exit_err(msg: &str) -> ! {
eprintln!("错误: {msg}");
std::process::exit(1);
}
fn ensure_single_target(system: bool, user: bool) -> &'static str {
if system && user { exit_err("不能同时指定 --system 和 --user"); }
if system { "system" } else { "user" }
}
type SaveFn = fn(Vec<String>) -> Result<(), String>;
fn verify_and_save(target: &str, original: &[String], new_list: Vec<String>) {
let reload = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if reload != original {
exit_err("注册表已被其他进程修改,请重新执行操作");
}
let save: SaveFn = if target == "system" { core::registry::save_system_paths } else { core::registry::save_user_paths };
save(new_list).unwrap_or_else(|e| exit_err(&e));
}
fn load_and_save(system: bool, f: impl FnOnce(Vec<String>) -> Vec<String>) {
let target = ensure_single_target(system, false);
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let new_list = f(list.clone());
verify_and_save(target, &list, new_list);
}
// ── 命令实现 ──
fn cmd_list(system: bool, user: bool, json_out: bool) {
let mut sys: Vec<String> = vec![];
let mut usr: Vec<String> = vec![];
if system || !user {
sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
}
if user || !system {
usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
}
if json_out {
let output = json!({ "system": { "paths": sys, "count": sys.len() }, "user": { "paths": usr, "count": usr.len() } });
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
if !sys.is_empty() {
println!("═══ 系统 PATH ({}) ═══", sys.len());
for (i, p) in sys.iter().enumerate() { println!(" [{}] {}", i, p); }
}
if !usr.is_empty() {
println!("═══ 用户 PATH ({}) ═══", usr.len());
for (i, p) in usr.iter().enumerate() { println!(" [{}] {}", i, p); }
}
}
}
fn cmd_add(path: String, system: bool, user: bool) {
let target = ensure_single_target(system, user);
load_and_save(system, |mut list| {
list.push(path.clone());
list
});
let label = if target == "system" { "系统" } else { "用户" };
println!("已添加到{} PATH: {path}", label);
core::system::broadcast_env_change();
}
fn cmd_remove(index: usize, system: bool) {
let target = ensure_single_target(system, false);
let mut list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let original = list.clone();
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let removed = list.remove(index);
verify_and_save(target, &original, list);
println!("已删除: {removed}");
core::system::broadcast_env_change();
}
fn cmd_edit(index: usize, new_path: String, system: bool) {
let target = ensure_single_target(system, false);
let mut list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let original = list.clone();
let old = std::mem::replace(&mut list[index], new_path.clone());
verify_and_save(target, &original, list);
println!("已编辑: {old}{new_path}");
core::system::broadcast_env_change();
}
fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
load_and_save(system, |mut list| {
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let end = if up {
index.saturating_sub(steps)
} else {
let max = list.len() - 1;
if index + steps > max { max } else { index + steps }
};
let removed = list.remove(index);
list.insert(end, removed);
list
});
let dir = if up { "上移" } else { "下移" };
println!("{dir} {steps} 格完成");
core::system::broadcast_env_change();
}
fn cmd_clean(system: bool, user: bool, dry_run: bool, json_out: bool) {
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 {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let (kept, removed) = core::registry::clean_paths(list.clone());
if json_out {
println!("{}", json!({ "target": target, "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }));
} else if dry_run {
println!("═══ {label} PATH — 将被移除({} 条)═══", removed.len());
for r in &removed { println!("{}", r); }
println!("═══ {label} PATH — 将保留({} 条)═══", kept.len());
for k in &kept { println!("{}", k); }
} else {
let kept_count = kept.len();
verify_and_save(target, &list, kept);
println!("{label} PATH 清理完成:移除 {} 条,保留 {}", removed.len(), kept_count);
if !removed.is_empty() {
for r in &removed { println!(" 已移除: {}", r); }
}
}
}
fn cmd_toggle(index: usize, system: bool, user: bool, enable: bool) {
let target = ensure_single_target(system, user);
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let path = &list[index];
let (mut sys_dis, mut usr_dis) = core::disabled::load_disabled_state().unwrap_or_else(|_| (vec![], vec![]));
let target_list: &mut Vec<String> = if target == "system" { &mut sys_dis } else { &mut usr_dis };
if enable {
target_list.retain(|p| p != path);
} else if !target_list.contains(path) {
target_list.push(path.clone());
}
core::disabled::save_disabled_state(sys_dis, usr_dis).unwrap_or_else(|e| exit_err(&e));
let action = if enable { "启用" } else { "禁用" };
println!("{action}: {path}");
}
fn cmd_import(file: String, target: String) {
let content = core::fs::read_text_file(&file).unwrap_or_else(|e| exit_err(&e));
let (sys, usr) = core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e));
match target.as_str() {
"system" => {
let orig = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("system", &orig, sys);
println!("已导入到系统 PATH");
}
"user" => {
let orig = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("user", &orig, usr);
println!("已导入到用户 PATH");
}
_ => {
let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let orig_usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("system", &orig_sys, sys);
verify_and_save("user", &orig_usr, usr);
println!("已导入到系统 + 用户 PATH");
}
}
core::system::broadcast_env_change();
}
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).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}");
} else {
println!("{content}");
}
}
fn cmd_backup() {
let path = core::backup::backup_registry(None).unwrap_or_else(|e| exit_err(&e));
println!("备份已保存: {path}");
}
fn cmd_conflicts(json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let conflicts = core::scanner::scan_conflicts(paths).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&conflicts).unwrap());
} else if conflicts.is_empty() {
println!("未发现可执行文件冲突。");
} else {
println!("═══ 可执行文件冲突({} 个)═══\n", conflicts.len());
for c in &conflicts {
println!(" {}", c.name);
for loc in &c.locations {
println!(" {} {}", if loc.priority == 0 { "✓ 优先" } else { "✗ 遮蔽" }, loc.dir);
}
println!();
}
}
}
fn cmd_scan(query: Option<String>, json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let groups = core::scanner::scan_tools(paths, query.unwrap_or_default()).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&groups).unwrap());
} else {
for g in &groups {
if !g.exists { println!(" {} (不存在)", g.dir); continue; }
println!("═══ {} ═══", g.dir);
for exe in &g.exes { println!(" {}", exe); }
}
}
}
fn cmd_check_admin(json_out: bool) {
let is_admin = core::system::check_admin();
if json_out {
println!("{}", json!({"admin": is_admin}));
} else {
println!("管理员权限: {}", if is_admin { "" } else { "" });
}
}
fn profile_list(json_out: bool) {
let list = core::profiles::list_profiles().unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&list).unwrap());
} else if list.is_empty() {
println!("暂无配置文件。");
} else {
for p in &list { println!(" {} ({})", p.name, p.modified); }
}
}
fn profile_save(name: 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 sys_entries = sys.into_iter().map(|p| core::ProfilePathEntry { path: p, enabled: true }).collect();
let usr_entries = usr.into_iter().map(|p| core::ProfilePathEntry { path: p, enabled: true }).collect();
core::profiles::save_profile(&name, sys_entries, usr_entries).unwrap_or_else(|e| exit_err(&e));
println!("已保存配置: {name}");
}
fn profile_load(name: String) {
let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e));
println!("═══ 系统 PATH ({} 条) ═══", data.sys.len());
for e in &data.sys { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
println!("═══ 用户 PATH ({} 条) ═══", data.user.len());
for e in &data.user { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
}
fn profile_apply(name: String) {
let data = core::profiles::load_profile(&name).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}\" 已写入注册表。");
}
fn profile_delete(name: String) {
core::profiles::delete_profile(&name).unwrap_or_else(|e| exit_err(&e));
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 {
Command::List { system, user, json } => cmd_list(system, user, json),
Command::Add { path, system, user } => cmd_add(path, system, user),
Command::Remove { index, system } => cmd_remove(index, system),
Command::Edit { index, new_path, system } => cmd_edit(index, new_path, system),
Command::MoveUp { index, steps, system } => cmd_move(index, steps, system, true),
Command::MoveDown { index, steps, system } => cmd_move(index, steps, system, false),
Command::Clean { system, user, dry_run, json } => cmd_clean(system, user, dry_run, json),
Command::Enable { index, system, user } => cmd_toggle(index, system, user, true),
Command::Disable { index, system, user } => cmd_toggle(index, system, user, false),
Command::Import { file, target } => cmd_import(file, target),
Command::Export { format, output } => cmd_export(format, output),
Command::Backup => cmd_backup(),
Command::Conflicts { json } => cmd_conflicts(json),
Command::Scan { query, json } => cmd_scan(query, json),
Command::CheckAdmin { json } => cmd_check_admin(json),
Command::Profile(cmd) => match cmd {
ProfileCmd::List { json } => profile_list(json),
ProfileCmd::Save { name } => profile_save(name),
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),
},
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "path-editor-core"
description = "PathEditor core library — shared between GUI and CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
winreg = "0.52"
dirs = "5"
chrono = "0.4"
@@ -1,25 +1,22 @@
use chrono::Local;
use std::path::PathBuf;
use winreg::enums::*;
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
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 路径下的备份目录
#[tauri::command]
/// 获取备份目录路径
pub fn get_appdata_dir() -> String {
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
#[tauri::command]
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),
@@ -66,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);
}
}
@@ -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")
}
@@ -19,7 +18,6 @@ struct DisabledState {
}
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
#[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
let state = DisabledState { system, user };
let path = disabled_file_path();
@@ -40,7 +38,6 @@ pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(),
}
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
#[tauri::command]
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
let path = disabled_file_path();
@@ -60,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());
}
}
+228
View File
@@ -0,0 +1,228 @@
// 注意: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))
}
/// 导入路径文件(JSON / CSV / TXT),返回 (系统路径, 用户路径)
pub fn import_paths(path: &str, content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let ext = std::path::Path::new(path)
.extension()
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let ext = ext.to_string_lossy();
match ext.as_ref() {
"json" => import_json(content),
"csv" => import_csv(content),
"txt" => import_txt(content),
_ => Err(format!("不支持的格式: .{}", ext)),
}
}
fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
#[derive(serde::Deserialize)]
struct ImportData {
#[serde(default)]
system: Vec<String>,
#[serde(default)]
user: Vec<String>,
}
let data: ImportData =
serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((data.system, data.user))
}
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 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() {
"system" | "sys" => sys.push(fields[1].trim().to_string()),
"user" | "usr" => usr.push(fields[1].trim().to_string()),
_ => { log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); }
}
} else {
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
}
}
if sys.is_empty() && usr.is_empty() {
return Err("CSV 文件中未找到有效路径".into());
}
Ok((sys, usr))
}
fn import_txt(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let paths: Vec<String> = content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
if paths.is_empty() {
return Err("TXT 文件中未找到路径".into());
}
// TXT 格式全部导入为用户路径
Ok((vec![], paths))
}
/// 导出 PATH 为指定格式字符串
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<String, String> {
match format {
"json" => {
let data = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
"system": sys,
"user": usr,
});
Ok(serde_json::to_string_pretty(&data).unwrap_or_default())
}
"csv" => {
let mut out = String::from("type,path\n");
for p in sys {
out.push_str(&format!("system,{}\n", p));
}
for p in usr {
out.push_str(&format!("user,{}\n", p));
}
Ok(out)
}
"txt" => {
let mut out = String::new();
if !sys.is_empty() {
out.push_str(&format!("# 系统 PATH ({})\n", sys.len()));
for p in sys {
out.push_str(&format!("{}\n", p));
}
}
if !usr.is_empty() {
out.push_str(&format!("# 用户 PATH ({})\n", usr.len()));
for p in usr {
out.push_str(&format!("{}\n", p));
}
}
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"]);
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod backup;
pub mod disabled;
pub mod fs;
pub mod profiles;
pub mod registry;
pub mod scanner;
pub mod system;
pub use profiles::{ProfileData, ProfileMeta, ProfilePathEntry};
pub use scanner::{ConflictEntry, ConflictLocation, ToolGroup};
+233
View File
@@ -0,0 +1,233 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.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))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
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,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
pub fn save_profile(
name: &str,
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 now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
// 覆盖已有配置时保留原始创建时间
let created = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str::<ProfileData>(&c).ok())
.map(|d| d.created)
.unwrap_or_else(|| now.clone())
} else {
now.clone()
};
let data = ProfileData {
name: name.to_string(),
sys,
user,
created,
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
pub fn load_profile(name: &str) -> Result<ProfileData, String> {
validate_profile_name(name)?;
let path = profile_path(name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
pub fn delete_profile(name: &str) -> Result<(), String> {
validate_profile_name(name)?;
let path = profile_path(name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
validate_profile_name(old_name)?;
validate_profile_name(new_name)?;
let old_path = profile_path(old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.to_string();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
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();
}
}
@@ -3,7 +3,7 @@ use winreg::RegKey;
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const USER_REG_PATH: &str = "Environment";
pub(crate) const PATH_VALUE: &str = "Path";
const PATH_VALUE: &str = "Path";
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
let key = RegKey::predef(root);
@@ -18,7 +18,7 @@ pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Res
Ok(split_path(&value))
}
pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
let value = join_path(paths);
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
@@ -43,36 +43,56 @@ pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths:
Ok(())
}
#[tauri::command]
/// 从 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, "系统")
}
#[tauri::command]
/// 从 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, "用户")
}
#[tauri::command]
/// 保存系统 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)
}
#[tauri::command]
/// 保存用户 PATH 到注册表
///
/// # Returns
/// - `Ok(())` — 保存成功
/// - `Err(String)` — 写入失败
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
}
/// 将分号分隔的 PATH 字符串拆分为数组。
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
pub(crate) fn split_path(raw: &str) -> Vec<String> {
fn split_path(raw: &str) -> Vec<String> {
raw.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn join_path(paths: &[String]) -> String {
fn join_path(paths: &[String]) -> String {
paths
.iter()
.map(|p| p.trim())
@@ -81,6 +101,29 @@ pub(crate) fn join_path(paths: &[String]) -> String {
.join(";")
}
/// 清理路径列表:移除不存在的目录 + 重复路径(保留首次出现)
/// 返回 (保留的路径, 被移除的路径)
pub fn clean_paths(paths: Vec<String>) -> (Vec<String>, Vec<String>) {
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
let mut kept = Vec::new();
let mut removed = Vec::new();
for p in paths {
let key = p.trim().to_lowercase();
if seen.contains(&key) {
removed.push(p);
continue;
}
seen.insert(key);
if !p.contains('%') && !std::path::Path::new(&p).is_dir() {
removed.push(p);
continue;
}
kept.push(p);
}
(kept, removed)
}
#[cfg(test)]
mod tests {
use super::*;
+181
View File
@@ -0,0 +1,181 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
#[derive(serde::Serialize, Clone)]
pub struct ConflictLocation {
pub dir: String,
pub priority: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct ConflictEntry {
pub name: String,
pub locations: Vec<ConflictLocation>,
}
#[derive(serde::Serialize)]
pub struct ToolGroup {
pub dir: String,
pub exists: bool,
pub exes: Vec<String>,
}
/// 扫描单个目录中的可执行文件名
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("")) {
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()
.filter(|(_, locs)| locs.len() >= 2)
.map(|(name, locs)| ConflictEntry {
name,
locations: locs
.into_iter()
.map(|(priority, dir)| ConflictLocation { dir, priority })
.collect(),
})
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果。各目录并行扫描。
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
let query_lower = query.to_lowercase();
// 并行扫描各目录
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 });
}
}
}
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");
}
}
@@ -1,8 +1,12 @@
use winreg::enums::*;
use winreg::RegKey;
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
#[tauri::command]
/// 检测当前进程是否有管理员权限
///
/// 通过尝试以写入权限打开系统 PATH 注册表键判断。
///
/// # Returns
/// `true` 表示有管理员权限,`false` 为只读模式
pub fn check_admin() -> bool {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey_with_flags(
@@ -14,7 +18,6 @@ pub fn check_admin() -> bool {
/// 验证路径是否存在于文件系统中(且是目录)
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
#[tauri::command]
pub fn validate_path(path: &str) -> bool {
if path.contains('%') {
return true;
@@ -23,7 +26,6 @@ pub fn validate_path(path: &str) -> bool {
}
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin
#[tauri::command]
pub fn expand_env_vars(path: &str) -> String {
if !path.contains('%') {
return path.to_string();
@@ -58,13 +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
#[tauri::command]
/// 广播 `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();
});
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist', 'src-tauri'] },
{ ignores: ['dist', 'gui'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+1 -1
View File
@@ -2271,7 +2271,7 @@ dependencies = [
[[package]]
name = "patheditor"
version = "4.0.0"
version = "5.0.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+6 -11
View File
@@ -1,11 +1,11 @@
[package]
name = "patheditor"
version = "4.2.0"
description = "Windows PATH Environment Variable Editor"
authors = ["刘航宇"]
license = "MIT"
repository = "https://github.com/LHY0125/PathEditor"
edition = "2021"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version = "1.77.2"
[lib]
@@ -16,14 +16,9 @@ crate-type = ["staticlib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0"
path-editor-core = { path = "../core" }
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
# Windows API
winreg = "0.52"
dirs = "5"
chrono = "0.4"
View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::backup;
#[tauri::command]
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> { backup::backup_registry(custom_dir) }
#[tauri::command]
pub fn get_appdata_dir() -> String { backup::get_appdata_dir() }
+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::disabled;
#[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> { disabled::save_disabled_state(system, user) }
#[tauri::command]
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> { disabled::load_disabled_state() }
+4
View File
@@ -0,0 +1,4 @@
use path_editor_core::fs;
#[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> { fs::read_text_file(path) }
+12
View File
@@ -0,0 +1,12 @@
use path_editor_core::profiles;
#[tauri::command]
pub fn list_profiles() -> Result<Vec<profiles::ProfileMeta>, String> { profiles::list_profiles() }
#[tauri::command]
pub fn save_profile(name: String, sys: Vec<profiles::ProfilePathEntry>, user: Vec<profiles::ProfilePathEntry>) -> Result<(), String> { profiles::save_profile(&name, sys, user) }
#[tauri::command]
pub fn load_profile(name: String) -> Result<profiles::ProfileData, String> { profiles::load_profile(&name) }
#[tauri::command]
pub fn delete_profile(name: String) -> Result<(), String> { profiles::delete_profile(&name) }
#[tauri::command]
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { profiles::rename_profile(&old_name, &new_name) }
+10
View File
@@ -0,0 +1,10 @@
use path_editor_core::registry;
#[tauri::command]
pub fn load_system_paths() -> Result<Vec<String>, String> { registry::load_system_paths() }
#[tauri::command]
pub fn load_user_paths() -> Result<Vec<String>, String> { registry::load_user_paths() }
#[tauri::command]
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> { registry::save_system_paths(paths) }
#[tauri::command]
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> { registry::save_user_paths(paths) }
+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::scanner;
#[tauri::command]
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<scanner::ConflictEntry>, String> { scanner::scan_conflicts(paths) }
#[tauri::command]
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<scanner::ToolGroup>, String> { scanner::scan_tools(paths, query) }
+10
View File
@@ -0,0 +1,10 @@
use path_editor_core::system;
#[tauri::command]
pub fn check_admin() -> bool { system::check_admin() }
#[tauri::command]
pub fn validate_path(path: &str) -> bool { system::validate_path(path) }
#[tauri::command]
pub fn expand_env_vars(path: &str) -> String { system::expand_env_vars(path) }
#[tauri::command]
pub fn broadcast_env_change() { system::broadcast_env_change() }
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "PathEditor",
"version": "4.2.0",
"version": "5.0.0",
"identifier": "com.liuhangyu.patheditor",
"build": {
"frontendDist": "../dist",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "PathEditor v4.2",
"title": "PathEditor v5.0",
"width": 900,
"height": 700,
"minWidth": 800,
+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",
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "patheditor",
"private": true,
"version": "4.2.0",
"version": "5.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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",
-5
View File
@@ -1,5 +0,0 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
#[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
-146
View File
@@ -1,146 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
#[tauri::command]
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
#[tauri::command]
pub fn save_profile(
name: String,
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let data = ProfileData {
name,
sys,
user,
created: now.clone(),
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
#[tauri::command]
pub fn load_profile(name: String) -> Result<ProfileData, String> {
let path = profile_path(&name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
#[tauri::command]
pub fn delete_profile(name: String) -> Result<(), String> {
let path = profile_path(&name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
#[tauri::command]
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> {
let old_path = profile_path(&old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.clone();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(&new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
-114
View File
@@ -1,114 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
#[derive(serde::Serialize, Clone)]
pub struct ConflictLocation {
pub dir: String,
pub priority: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct ConflictEntry {
pub name: String,
pub locations: Vec<ConflictLocation>,
}
#[derive(serde::Serialize)]
pub struct ToolGroup {
pub dir: String,
pub exists: bool,
pub exes: Vec<String>,
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
#[tauri::command]
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))?;
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()));
}
}
}
}
let mut results: Vec<ConflictEntry> = map
.into_iter()
.filter(|(_, locs)| locs.len() >= 2)
.map(|(name, locs)| ConflictEntry {
name,
locations: locs
.into_iter()
.map(|(priority, dir)| ConflictLocation { dir, priority })
.collect(),
})
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
#[tauri::command]
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());
}
}
}
}
exes.sort();
groups.push(ToolGroup {
dir: dir.clone(),
exists: true,
exes,
});
}
Ok(groups)
}
+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
@@ -27,7 +27,7 @@ export function join_path(paths: string[]): string {
}
/** 分割 PATH 字符串。
* 注意:Rust 端 src-tauri/src/commands/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
* 注意:Rust 端 core/src/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
export function split_path(raw: string): string[] {
return raw
.split(';')
+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');
});
});
+1 -1
View File
@@ -8,6 +8,6 @@ export default defineConfig({
},
},
test: {
exclude: ['e2e/**', 'node_modules/**', 'src-tauri/**'],
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
},
});