Compare commits

..

48 Commits

Author SHA1 Message Date
Serendipity d5f3f5750c docs: 添加应用截图到 README
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
2026-06-19 19:19:58 +08:00
Serendipity 8c0e80d862 chore: 开源项目基础设施全面完善
新增配置文件:
- .editorconfig — 跨编辑器代码风格统一
- .gitattributes — 行尾符 CRLF 规范化
- .prettierrc + .prettierignore — 前端代码格式化
- .markdownlint.json — Markdown 格式规范
- commitlint.config.js — Conventional Commits 强制校验

新增 GitHub 社区文件:
- .github/dependabot.yml — 依赖自动更新 (npm + Cargo + Actions)
- .github/CODEOWNERS — 自动 PR 审查分配
- .github/FUNDING.yml — 开源赞助入口

新增文档:
- ROADMAP.md — v5.1/v5.2/v6.0 路线图
- SUPPORT.md — 帮助与支持指南
- docs/screenshots/ — 截图目录就位

新增 Git Hooks:
- .husky/pre-commit — lint-staged 自动格式化+修复
- .husky/commit-msg — commitlint 校验提交消息

CI 强化 (.github/workflows/ci.yml):
- 新增 Prettier 格式检查步骤
- 新增 cargo fmt --check 步骤
- 新增 Vitest 覆盖率生成 + Codecov 上报

修复:
- index.html 标题 v4.0 → v5.0
- PathEditDialog set-state-in-effect 改用 useRef prevOpen 守卫
- use-app-actions.test.tsx 缺失 @vitest-environment jsdom
- 所有 TS/TSX 文件 Prettier 格式化统一

配置更新:
- vitest.config.ts — v8 覆盖率 + 阈值门禁 (60%/70%)
- package.json — format/format:check/test:coverage/prepare 脚本 + lint-staged
- .gitignore — 新增 coverage/sync-conflict/playwright-report
- README.md — 新增 coverage + platform 徽章 + 截图区域
2026-06-19 19:12:11 +08:00
Serendipity 5c73321ce6 fix: 修复 ESLint set-state-in-effect 错误 — useEffect 加 prevOpen 守卫
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:02:55 +08:00
Serendipity 44cd6c6595 fix: CI 修复 tsc 项目引用 + ESLint 限制扫描范围 + 新增 .eslintignore
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:53:52 +08:00
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
Serendipity 26f6953919 fix: ProfileDialog 标题栏添加 ✕ 关闭按钮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:29:50 +08:00
Serendipity 5ed15535e7 fix: 深色模式下选中行对比度不足 — 新增 CSS 变量分别适配浅色/深色主题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:25:54 +08:00
Serendipity 230fb5d741 fix: 配置文件目录从 %APPDATA% 改为 %USERPROFILE%/.patheditor/profiles
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:20:38 +08:00
Serendipity d7d11480b8 feat: PATH 配置文件/预设切换 — 保存、加载、一键应用不同场景的 PATH 配置
- 新增 profiles.rs: list/save/load/delete/rename 五个 Rust 命令
- 配置文件存储在 %APPDATA%/.patheditor/profiles/<name>.json
- ProfileDialog: 保存当前 PATH、加载预览、一键应用到注册表
- 工具栏新增「配置」按钮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:02:29 +08:00
Serendipity 7869886670 feat: 新增 PATH 智能分析功能 — 冲突检测 + 工具清单
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
- scan_conflicts: 检测不同目录中的同名可执行文件(遮蔽冲突)
- scan_tools: 扫描各目录提供的可执行文件,支持关键词搜索
- Rust scanner.rs 后端,前端 AnalyzeDialog 弹窗
- 工具栏新增「分析」按钮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:02:12 +08:00
Serendipity 49ef9c0cff chore: 添加 Issue 模板、CI 徽章、修复仓库描述
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
- 新增 bug_report 和 feature_request Issue 模板
- README 添加 GitHub Actions CI 状态徽章
- 修复仓库描述(去重,更新至 v4.2)
- 默认分支改为 main

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:46:56 +08:00
Serendipity 344011a02c chore: 整理仓库 — 版本号统一、README 徽章更新、新增 CHANGELOG 和 CONTRIBUTING
- 版本号 4.0.0 → 4.2.0(tauri.conf.json, Cargo.toml, 窗口标题)
- README 徽章更新(tests 55→72, version 4.0.0→4.2.0)
- CHANGELOG.md 补充 v4.1 和 v4.2 变更记录
- 新增 CONTRIBUTING.md 贡献指南
- GitHub Release: v4.2.0 补充说明,旧 C 版本标记为 pre-release

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:43:36 +08:00
Serendipity 3aed03f599 fix: 修复 5 个 bug + 备份警告丢失
- BUG 1: undo/redo 后持久化 disabled 状态到 disabled.json
- BUG 2: expand_env_vars 增加缓冲区不足检测(result > required)
- BUG 3: E2E mock load_disabled_state 返回格式从对象改为数组
- BUG 4: 双 hive 保存失败时同时显示两个错误原因
- BUG 5: 导入 both 合并为单条 undo 记录(新增 IMPORT_BOTH 操作类型)
- 备份失败后保存成功时显示"保存成功(备份失败)"而非覆盖警告

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:38:32 +08:00
Serendipity d7bc752b84 fix: release workflow 兼容已存在的 release + 版本号升到 4.2.0
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:54:52 +08:00
Serendipity c1975e836c chore: 移除调试文件,更新 .gitignore
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:34:23 +08:00
Serendipity be04b7d0da fix: 修复 ESLint 错误 — path-manager 测试去 as any、search-clean 未用参数
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:26:56 +08:00
Serendipity 2b372cbf89 chore: 添加 vitest.config.ts — 排除 e2e 测试目录,配置 @/ 路径别名
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:18:42 +08:00
Serendipity 45e2a4e584 test: 新增 4 条 E2E 测试 — 启动加载、CRUD撤销、禁用保存、搜索清理 2026-05-27 14:16:24 +08:00
Serendipity ff343185c9 chore: 安装 Playwright + 配置 E2E 基础框架
- 安装 @playwright/test 1.60.0
- 创建 e2e/playwright.config.ts(webServer 自动启动 vite dev)
- 创建 e2e/mocks/ipc.ts(Tauri IPC mock)
- 新增 npm run test:e2e 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:01:47 +08:00
Serendipity 6d711d0f8e test: 修复所有测试适配 PathEntry,全部通过
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:59:40 +08:00
Serendipity d6e535aa98 feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:56:52 +08:00
Serendipity e646a84291 feat: app-store 适配 PathEntry — 新增 togglePath、loadPaths 合并禁用状态、savePaths 过滤 disabled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:49:26 +08:00
Serendipity 611a36fb98 refactor: core 模块适配 PathEntry — path-manager、import-export 类型迁移
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:45:05 +08:00
Serendipity ab2d0da20c feat: 新增 disabled.rs — 禁用路径 JSON 文件读写
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:44:24 +08:00
Serendipity 914b25f236 test: 所有测试适配 PathEntry[] 类型,新增 TOGGLE undo/redo 测试
- undo-redo.test.ts: 所有 11 项测试通过(含新增 TOGGLE 撤销/重做)
- app-store.test.ts: 断言改用 .map(e => e.path),待 Task 5 修复
- import-export.test.ts: sampleData 改用 pe(),导出断言适配
- path-manager.test.ts: 测试数据用 pe() 包裹,待 Task 3 修复
- validation.test.ts: 无需变更(纯 string 接口)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:42:21 +08:00
Serendipity 32287c0e4b feat: 新增 PathEntry 类型 + TOGGLE 操作类型,undo-redo 用 PathEntry[] 替代 string[]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:32:35 +08:00
Serendipity 71b98e308a docs: 添加 v4.3 路径启用/禁用 + E2E 测试实现计划(10 个 Task)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:21:35 +08:00
Serendipity fcd4796fee docs: 添加 v4.3 路径启用/禁用 + E2E 测试设计文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:19:02 +08:00
Serendipity 8ff02fd88b fix: 修复 ESLint 错误 — PathEditDialog/use-keyboard/test 添加规则豁免注释
这些是正当的 React 模式(对话框状态重置、ref 同步避免重复注册、测试 mock)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:07:57 +08:00
Serendipity 39a95cc50d fix: CI 切换到 MSVC 工具链、添加 eslint.config.js
- Rust: windows-latest 默认 MSVC,无需额外安装 GNU/MinGW
- ESLint: 添加 eslint flat config(js + tseslint + react-hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:58:30 +08:00
Serendipity 44fdc2eec6 fix: release 改用 gh release create、ci 添加 permissions: contents: read
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:54:07 +08:00
Serendipity 6dc32dca93 ci: 添加 CI workflow — push 自动检查 TypeScript + Rust
前端: tsc --noEmit + ESLint + Vitest (ubuntu)
Rust: cargo check + clippy + test (windows + GNU toolchain)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:52:13 +08:00
Serendipity a2b66d087f ci: 添加 Release workflow — tag 推送自动构建 NSIS 安装包并发布
tag v* 触发 npx tauri build,NSIS 安装包上传到 GitHub Release

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:52:12 +08:00
Serendipity 8c1655d25c docs: 添加 v4.2 CI/CD 流水线设计文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:50:33 +08:00
137 changed files with 14461 additions and 678 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+24
View File
@@ -0,0 +1,24 @@
# EditorConfig — 跨编辑器统一代码风格
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = crlf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.{rs,toml}]
indent_size = 4
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
target/
test-results/
e2e/
*.config.*
+32
View File
@@ -0,0 +1,32 @@
# Git 行尾符规范化
# 本仓库统一 CRLFWindows 原生项目)
# 源码文本文件
*.ts text eol=crlf
*.tsx text eol=crlf
*.js text eol=crlf
*.json text eol=crlf
*.html text eol=crlf
*.css text eol=crlf
*.md text eol=crlf
*.rs text eol=crlf
*.toml text eol=crlf
*.yml text eol=crlf
*.yaml text eol=crlf
*.svg text eol=crlf
*.txt text eol=crlf
*.editorconfig text eol=crlf
*.gitattributes text eol=crlf
*.gitignore text eol=crlf
LICENSE text eol=crlf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.dll binary
*.exe binary
*.nsis binary
+20
View File
@@ -0,0 +1,20 @@
# 代码所有者 — 自动分配 PR 审查
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# 全局所有者
* @LHY0125
# Rust 代码
/core/ @LHY0125
/cli/ @LHY0125
/gui/ @LHY0125
/Cargo.toml @LHY0125
/rust-toolchain.toml @LHY0125
# 前端代码
/src/ @LHY0125
/tests/ @LHY0125
/e2e/ @LHY0125
# CI/CD 和配置文件
/.github/ @LHY0125
+5
View File
@@ -0,0 +1,5 @@
# 开源赞助
# 支持 PathEditor 的开发
github: LHY0125
# 如需定制功能或商业授权,请通过 GitHub Issues 联系
+27
View File
@@ -0,0 +1,27 @@
---
name: Bug 报告
about: 提交问题报告帮助改进 PathEditor
title: "[Bug] "
labels: bug
assignees: ''
---
## 问题描述
<!-- 清晰描述 bug 是什么 -->
## 复现步骤
1.
2.
3.
## 期望行为
## 截图(如有)
## 系统信息
- Windows 版本:
- 是否管理员:
- PathEditor 版本:
+15
View File
@@ -0,0 +1,15 @@
---
name: 功能建议
about: 建议新功能或改进
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 使用场景
<!-- 你会在什么场景下需要这个功能? -->
## 建议方案
<!-- 你期望的功能是什么样的? -->
+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 改动,请附上前后对比截图 -->
+73
View File
@@ -0,0 +1,73 @@
# Dependabot 自动依赖更新配置
# https://docs.github.com/code-security/dependabot/dependabot-version-updates
version: 2
updates:
# npm 前端依赖
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
versioning-strategy: "auto"
allow:
- dependency-type: "all"
labels:
- "dependencies"
- "javascript"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 5
groups:
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
tauri:
patterns:
- "@tauri-apps/*"
testing:
patterns:
- "@testing-library/*"
- "@playwright/test"
- "vitest"
- "jsdom"
eslint:
patterns:
- "eslint"
- "eslint-plugin-*"
- "typescript-eslint"
- "globals"
- "@eslint/js"
# Cargo Rust 依赖
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
labels:
- "dependencies"
- "rust"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 3
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "ci(deps)"
+63
View File
@@ -0,0 +1,63 @@
name: CI
on:
push:
branches:
- '**'
tags-ignore:
- '**'
permissions:
contents: read
jobs:
frontend:
name: 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Prettier 格式检查
run: npx prettier --check "src/**/*.{ts,tsx}" "tests/**/*.{ts,tsx}" "e2e/**/*.ts"
- name: TypeScript 类型检查
run: npx tsc -b --noEmit
- name: ESLint
run: npx eslint src/ tests/
- name: Vitest 测试 + 覆盖率
run: npx vitest run --coverage
- name: 上传覆盖率到 Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/cobertura-coverage.xml
flags: frontend
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
rust:
name: Rust 检查 (格式 + Check + Clippy + Test)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Cargo Format
run: cargo fmt --check
- name: Cargo Check
run: cargo check
- name: Cargo Clippy
run: cargo clippy -- -D warnings
- name: Cargo Test
run: cargo test
+36
View File
@@ -0,0 +1,36 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
name: 构建 NSIS 安装包并发布
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Tauri Build
run: npx tauri build
- name: 上传安装包到 Release
run: |
$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 {
gh release create $env:GITHUB_REF_NAME "$installer" --title "$env:GITHUB_REF_NAME" --generate-notes
}
env:
GH_TOKEN: ${{ github.token }}
+24
View File
@@ -12,8 +12,21 @@ dist
dist-ssr
*.local
# Coverage
coverage/
*.lcov
# Sync conflicts
*.sync-conflict-*
# Test artifacts
test-results/
playwright-report/
.nyc_output/
# Editor directories and files
.vscode/*
.codegraph/*
!.vscode/extensions.json
.idea
.DS_Store
@@ -22,5 +35,16 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# AI assistant
.claude/
CLAUDE.md
# Platform
e2e/debug-screenshot.png
target/
# Archive
*.zip
*.7z
*.tar.gz
+1
View File
@@ -0,0 +1 @@
npx --no -- commitlint --edit $1
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+8
View File
@@ -0,0 +1,8 @@
{
"default": true,
"MD013": false,
"MD033": {
"allowed_elements": ["img", "br", "kbd", "summary", "details"]
},
"MD041": false
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-ssr
target
*.local
*.log
test-results
coverage
Cargo.lock
package-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "crlf",
"arrowParens": "always",
"bracketSpacing": true
}
+43 -27
View File
@@ -1,38 +1,54 @@
# Changelog
## v4.0.0 (2026-05-26)
## 5.0.0 (2026-05-29)
### 重大变更
### Added
- Cargo workspace 三层架构 (core + gui + cli)
- CLI 命令行工具,17 条命令,支持 JSON 输出
- PATH 可执行文件冲突检测 (`scan_conflicts`)
- PATH 目录工具清单 (`scan_tools`)
- 配置文件管理:保存/加载/应用/重命名/删除
- 系统+用户合并预览视图
- CLI 原子性保护:写入前重新读取注册表对比
- `--steps N` 参数支持多格移动 (CLI 特有)
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
### Changed
- Rust + Tauri 2.x + React 19 + TypeScript strict 全重写
- 撤销/重做系统扩展至 10 种操作类型
- 禁用状态即时持久化,不依赖保存按钮
- 深色模式 / 浅色模式 CSS 变量驱动
- 中英双语界面 (i18next)
- 备份文件存储路径统一到 `~/.patheditor/`
- 版本号集中管理: Rust 端 `Cargo.toml` workspace, 前端 `package.json`
### 新增
### Fixed
- 非管理员自动进入只读模式
- 保存失败精确提示哪个注册表 hive 出错 (Promise.allSettled)
- CLI `--system`/`--user` 互斥校验
- 修改操作后广播 `WM_SETTINGCHANGE`
- 深色模式下行选中颜色对比度不足
- 窗口内容溢出无法滚动
- 现代 Web UIReact + Tailwind CSS 4 + Zustand
- 深色/浅色模式切换
- 中英文界面即时切换
- 路径有效性颜色编码(红色无效、橙色重复)
- 环境变量展开悬停提示
- 文件夹拖拽添加路径
- 保存前 PATH 长度检查
- 66 个前端单元测试 + 10 个 Rust 单元测试
## 4.2.0
### 改进
### Fixed
- Release workflow 兼容已存在的 release
- 安装包体积从 ~3MB 降至 ~8MB(含 WebView2 运行时)
- 完整撤销/重做支持(8 种操作类型,50 步历史)
- JSON/CSV/TXT 三种格式导入导出
- 合并预览查看系统+用户路径
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
## 4.1.0
### 移除
### Added
- 路径验证 (红色无效、橙色重复)
- 环境变量路径悬浮展开预览
- 全局键盘快捷键
- 修改状态指示 + 未保存退出确认
- 旧 C + IUP + Lua + gettext 代码库
- Lua 配置引擎 → JSON 配置文件
- gettext 国际化 → i18next
## 4.0.0
### 已知限制
- 需要 Windows 10+ 系统预装的 WebView2 运行时
- 内存占用约 50MB(旧版约 15MB)
- 文件系统路径验证在清理功能中为同步检查(不含实际目录存在性验证)
### 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 版。
+74
View File
@@ -0,0 +1,74 @@
# 贡献指南
## 本地开发环境
- **Node.js** 22+
- **Rust** 1.95+ (stable-x86_64-pc-windows-gnu)
- **MinGW-w64** (GCC 15.x 需 `-lmcfgthread` 链接标志)
- **Windows 10+** (自带 WebView2)
## 开发流程
1. Fork 本仓库
2. `git clone <你的 fork>`
3. `git checkout -b feature/xxx`
4. 开发 + 测试
5. `git commit` (遵循约定式提交格式)
6. `git push`
7. 提交 Pull Request
## 运行测试
```bash
# 前端单元测试
npm test
# Rust 测试
cargo test
# E2E 测试 (需要先 npm run dev)
npx playwright test
# Clippy 检查
cargo clippy -- -D warnings
```
## 代码规范
### 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"
+219 -45
View File
@@ -4,30 +4,197 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-4.0.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">
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
<img src="https://img.shields.io/badge/tests-55%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
<a href="https://codecov.io/gh/LHY0125/PathEditor"><img src="https://codecov.io/gh/LHY0125/PathEditor/branch/v5.0/graph/badge.svg" alt="coverage"></a>
<img src="https://img.shields.io/badge/platform-Windows%2010%2B-0078D6" alt="platform">
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
</p>
---
## 截图
### 主界面
![主界面](docs/screenshots/main-window.png)
### 路径编辑
![路径编辑](docs/screenshots/path-edit.png)
### 冲突检测
![冲突检测](docs/screenshots/conflict-analysis.png)
### CLI 命令行
![CLI](docs/screenshots/cli-demo.png)
---
## 简介
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
v4.0 使用 **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`
## 功能
### 路径管理
- 查看和编辑 **系统 PATH**HKLM)和 **用户 PATH**HKCU
- 新建、编辑、删除、上移、下移路径条目
- 多选批量删除
@@ -36,26 +203,31 @@ _[待补充]_
- 文件夹拖拽添加
### 路径验证
- **红色**标记:路径在文件系统中不存在
- **橙色**标记:路径在列表中重复出现
- 环境变量路径(含 `%VAR%`)悬浮展开预览
### 撤销/重做
- 支持 8 种操作类型,最多 50 步历史
- 支持 9 种操作类型,最多 50 步历史
- 新增、删除、编辑、移动、清理、清空、导入均可撤销
### 导入/导出
- **JSON**:结构化导出,含版本和时间戳
- **CSV**UTF-8 BOM 编码,兼容 Excel
- **TXT**:纯文本,每行一个路径
### 安全
- 保存前自动备份注册表到 `%APPDATA%/PathEditor/backups/`
- PATH 长度检查(Windows 单变量上限 32767 字符)
- 非管理员自动进入**只读模式**
- 保存中途失败精确提示哪个注册表 hive 出错
### 界面
- 深色模式 / 浅色模式
- 中文 / English 界面切换
- 全局键盘快捷键
@@ -63,7 +235,7 @@ _[待补充]_
## 安装
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.0.0_x64-setup.exe` 安装。
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_5.0.0_x64-setup.exe` 安装。
或从源码构建:
@@ -80,7 +252,7 @@ npx tauri build
## 开发
```bash
# 开发模式(热更新)
# 开发模式 GUI(热更新)
npx tauri dev
# 仅前端
@@ -89,64 +261,66 @@ 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
```
### 技术栈
| 层 | 技术 |
|---|---|
| 前端框架 | React 19 + TypeScript (strict) |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 后端 | Rust (winreg + windows-rs FFI) |
| 前端测试 | Vitest (45 个测试) |
| Rust 测试 | cargo test (10 个测试) |
| 构建 | Vite |
| 打包 | NSIS |
| 层 | 技术 |
| --------- | --------------------------------- |
| 前端框架 | React 19 + TypeScript (strict) |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 核心库 | Rust workspace (core + gui + cli) |
| 前端测试 | Vitest (72 个测试) |
| Rust 测试 | cargo test (10 个测试) |
| 构建 | 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/ # 前端单元测试
```
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| 快捷键 | 功能 |
| -------- | -------- |
| `Ctrl+N` | 新建路径 |
| `Ctrl+S` | 保存 |
| `Ctrl+Z` | 撤销 |
| `Ctrl+Y` | 重做 |
| `Ctrl+F` | 搜索 |
| `Ctrl+S` | 保存 |
| `Ctrl+Z` | 撤销 |
| `Ctrl+Y` | 重做 |
| `Ctrl+F` | 搜索 |
| `Delete` | 删除选中 |
| `F1` | 帮助 |
| `F1` | 帮助 |
## 贡献
+48
View File
@@ -0,0 +1,48 @@
# 路线图
PathEditor 的未来发展方向。
## v5.1 (下一个版本)
- [ ] **CLI 模块化**`cli/src/main.rs` 拆分为 `commands/` 子模块
- [ ] **自动更新** — 内置 Tauri updater,无需手动下载安装包
- [ ] **深色模式优化** — 对齐 Windows 系统主题自动切换
- [ ] **性能优化** — 虚拟滚动支持超长 PATH 列表(1000+ 条目)
## v5.2
- [ ] **PATH 历史快照** — 保存每次修改的时间线,支持回退到任意历史节点
- [ ] **规则引擎** — 自定义 PATH 整理规则(如「所有 Python 路径放最前」)
- [ ] **收藏夹** — 常用路径快速添加
- [ ] **冲突解决方案引导** — 可视化的可执行文件冲突对比与解决建议
## v6.0 (长期)
- [ ] **跨平台支持** — 适配 Linux (`/etc/environment` + `~/.profile`) 和 macOS (`path_helper`)
- [ ] **Web 管理面板** — 远程管理多台 Windows 服务器的 PATH 环境变量
- [ ] **插件系统** — 第三方扩展生态(如 Anaconda/VSCode/VS 自动检测与配置)
- [ ] **Windows Package Manager 集成** — 与 winget/chocolatey 联动,检测包管理器安装的路径
## 已交付
### v5.0.0
- ✅ Cargo workspace 三层架构 (core + gui + cli)
- ✅ CLI 命令行工具 (18 条命令)
- ✅ 冲突检测 + 工具清单
- ✅ 配置文件管理
- ✅ 撤销/重做 (10 种操作)
- ✅ 中英双语界面
- ✅ CI/CD 自动化
### v4.x 系列
- ✅ Tauri 2.x 重写
- ✅ 路径验证 (红色/橙色标记)
- ✅ 导入/导出 JSON/CSV/TXT
- ✅ 深色/浅色模式
- ✅ 全局键盘快捷键
---
欢迎通过 [Issues](https://github.com/LHY0125/PathEditor/issues) 提交功能建议!
+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/` 目录设为仅当前用户可读。
+45
View File
@@ -0,0 +1,45 @@
# 获取帮助
## 📖 文档
- [README](README.md) — 项目简介、功能列表、安装指南
- [CONTRIBUTING](CONTRIBUTING.md) — 贡献指南
- [CHANGELOG](CHANGELOG.md) — 版本变更记录
- [ROADMAP](ROADMAP.md) — 未来规划
- [SECURITY](SECURITY.md) — 安全政策
## 🐛 报告 Bug
1. 先搜索 [Issues](https://github.com/LHY0125/PathEditor/issues) 确认未被报告
2. 使用 **Bug Report** 模板创建新 Issue
3. 提供系统信息(Windows 版本、PathEditor 版本)
4. 附上复现步骤和截图
## 💡 功能建议
1. 检查 [ROADMAP](ROADMAP.md) 确认不在已有计划中
2. 使用 **Feature Request** 模板创建新 Issue
3. 描述使用场景和期望行为
## ❓ 常见问题
### CLI 命令找不到?
```bash
patheditor --help
```
确保已通过 `cargo install --path cli` 安装,且 `~/.cargo/bin` 在 PATH 中。
### 提示权限不足?
编辑系统 PATH 需要管理员权限。右键以管理员身份运行,或使用 CLI `patheditor check-admin` 检测。
### 保存后环境变量未生效?
PathEditor 会自动广播 `WM_SETTINGCHANGE`,但部分程序需要手动重启才能识别新 PATH。
## 📧 联系
- GitHub Issues: [LHY0125/PathEditor](https://github.com/LHY0125/PathEditor/issues)
- 安全问题: 参见 [SECURITY.md](SECURITY.md)
+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),
},
}
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('@commitlint/types').UserConfig} */
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'perf', 'ci', 'style', 'revert'],
],
'subject-case': [0],
},
};
+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);
}
}
+86
View File
@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn disabled_file_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("disabled.json")
}
#[derive(Serialize, Deserialize, Default)]
struct DisabledState {
#[serde(default)]
system: Vec<String>,
#[serde(default)]
user: Vec<String>,
}
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
let state = DisabledState { system, user };
let path = disabled_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建配置目录: {}", e))?;
}
let json = serde_json::to_string_pretty(&state)
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json)
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
log::info!("已保存禁用状态到: {}", path.display());
Ok(())
}
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
let path = disabled_file_path();
if !path.exists() {
return Ok((vec![], vec![]));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
if content.trim().is_empty() {
return Ok((vec![], vec![]));
}
let state: DisabledState = serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
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();
@@ -53,18 +55,28 @@ pub fn expand_env_vars(path: &str) -> String {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 {
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
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;
+36
View File
@@ -0,0 +1,36 @@
# 未修复问题清单
> 从 v5.1 全面代码审查中筛选,暂不修复,留待后续评估。
---
## 1. CLI main.rs 单体文件 (639 行)
**严重级别**: LOW
**文件**: `cli/src/main.rs`
**问题**: 所有 18 条 CLI 命令集中在一个文件中。
**建议**: 当前规模尚可维护,等到命令数超过 25 条或文件超过 1000 行时再拆分为 `commands/` 子模块。
---
## 2. GUI 命令层零测试
**严重级别**: LOW
**文件**: `gui/src/commands/*.rs` (8 个文件)
**问题**: GUI 命令层是纯薄包装,无独立测试。
**建议**: 不值得投入 — 命令正确性由编译器类型系统保证,运行期由 57 个 core 测试 + E2E 覆盖。
---
## 已修复(本批次)
- ~~disabled.rs 测试写入真实文件~~ → `#[cfg(test)]` 条件编译重定向到 `std::env::temp_dir()`
- ~~profiles.rs 同款问题~~ → 同上
---
_更新于: 2026-05-30 | 审查批次: v5.1 代码审查_
+233
View File
@@ -0,0 +1,233 @@
# PathEditor v4.2 代码审查 — 隐含 Bug 分析报告
> 审查日期:2026-05-28 | 审查范围:全部前端核心模块 + Rust 后端 + E2E 测试
---
## BUG 1 [HIGH] — undo/redo TOGGLE 后不持久化 disabled 状态
### 位置
`src/store/app-store.ts``togglePath()` (L213-237) vs `undo()` (L239-248) / `redo()` (L251-260)
### 现象
1. 用户勾选复选框禁用一个路径 → `togglePath` 立即调用 `invoke('save_disabled_state', ...)` 写入 `disabled.json`
2. 用户按 Ctrl+Z 撤销 → `undo()` 恢复内存中的 `enabled` 状态,但**不调用** `save_disabled_state`
3. `disabled.json` 中该路径仍标记为 disabled
4. 下次启动 `loadPaths` 时,从 `disabled.json` 读到的是旧数据,路径又被标记为 disabled
### 根因
`undo()``redo()` 中处理 TOGGLE 时,只更新了 Zustand store 中的 `sysPaths`/`userPaths`,没有像 `togglePath` 那样同步持久化 disabled 状态。
```typescript
// togglePath — 有持久化
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }).catch(() => {});
// undo/redo — 没有持久化!
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
});
```
### 影响
- 撤销 toggle 后刷新/重启应用,disabled 状态恢复为撤销前的值
- 数据和 UI 展示不一致
### 修复方向
`undo()``redo()` 中,检测当前/上一条记录是否为 TOGGLE 类型,如果是则同步调用 `save_disabled_state`。或者更通用的方案:在 `set()` 之后统一检查 disabled 状态是否有变化并持久化。
---
## BUG 2 [MEDIUM] — `expand_env_vars` 未检测缓冲区截断
### 位置
`src-tauri/src/commands/system.rs:40-58``expand_env_vars()`
### 现象
Windows API `ExpandEnvironmentStringsW` 的行为:
- 第 1 次调用(`lpDst = NULL, nSize = 0`):返回所需缓冲区大小(TCHAR 数)
- 第 2 次调用(提供缓冲区):若缓冲区足够,返回写入的 TCHAR 数(≤ nSize);**若缓冲区不足,返回所需大小(> nSize),而非 0**
当前代码:
```rust
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
// ...
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 {
// 仅处理了 API 失败
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
return path.to_string();
}
// result > 0 但可能 > required(截断!)— 未检测
```
### 触发条件(低概率但真实存在)
环境变量在两次 `ExpandEnvironmentStringsW` 调用之间被修改:
1. 第 1 次调用查询到 `%VAR%` 展开后为 50 字符,`required = 51`(含 null
2. 在第 1 次和第 2 次调用之间,另一个进程修改了 `%VAR%` 使其变为 200 字符
3. 第 2 次调用时 51 大小的缓冲区不够,API 返回 201
4. `result = 201 > 51``result != 0`,代码未进入错误分支
5. 函数返回截断的不完整路径
### 修复方向
```rust
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
return path.to_string();
}
```
---
## BUG 3 [MEDIUM] — E2E mock `load_disabled_state` 返回格式与 Rust 端不匹配
### 位置
- `e2e/mocks/ipc.ts:9`
- `src-tauri/src/commands/disabled.rs:44``load_disabled_state()`
- `src/store/app-store.ts:275``loadPaths()` 消费端
### 现象
**Rust 端返回类型**`Result<(Vec<String>, Vec<String>), String>`
Tauri 将元组序列化为 JSON 数组:`[["disabled_sys_1"], ["disabled_usr_1"]]`
**Mock 返回**
```javascript
case 'load_disabled_state': return { system: [], user: [] };
// ^^^^^^^^^^^^^^^^^^^^^^^^ 这是一个对象!
```
**前端消费**
```typescript
const result = await invoke<[string[], string[]]>('load_disabled_state');
sysDisabled = result[0]; // 从对象取 → undefined
usrDisabled = result[1]; // 从对象取 → undefined
new Set(sysDisabled); // → TypeError: undefined is not iterable
```
try/catch 捕获了 TypeError`sysDisabled`/`usrDisabled` 保持为初始空数组 `[]`
### 影响
- 生产环境无影响(Rust 端正确返回数组)
- E2E 测试中**disabled state 加载路径完全未被测试**(被 try/catch 静默跳过)
- 如果将来 E2E 测试要覆盖 disabled state 的加载-合并逻辑,会得到错误结果
### 修复方向
```javascript
case 'load_disabled_state': return []; // 返回空元组 [[], []]
// 或者如果要 mock 有禁用路径的场景:
case 'load_disabled_state': return [['C:\\disabled_path'], []];
```
---
## BUG 4 [LOW] — `savePaths` 双 hive 失败时丢失错误原因
### 位置
`src/store/app-store.ts:332-336`
### 现象
```typescript
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
const msg = sysOk ? '用户 PATH 保存失败' :
userOk ? '系统 PATH 保存失败' :
`保存失败: ${reason}`;
```
当**两个 hive 都保存失败**时:
- `sysOk` = false,跳过第 1 个三元分支
- `userOk` = false,跳过第 2 个三元分支
- `reason` 取的是 `sysResult.reason`(第 1 行),`userResult.reason` 被丢弃
- 最终消息:`保存失败: <仅系统 PATH 的错误原因>`
### 修复方向
```typescript
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
const parts = [sysErr, usrErr].filter(Boolean);
const msg = parts.length > 0 ? `保存失败: ${parts.join('; ')}` : '保存失败';
```
或者更清晰地分别显示两个 hive 的状态。
---
## BUG 5 [LOW] — `handleImportSelect` 导入 both 产生两条 undo 记录
### 位置
`src/hooks/use-app-actions.ts:160-165`
### 现象
```typescript
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
const { system, user } = dialogs.importDialog;
const flat = flattenImportResult({ system, user }, target);
if (flat.system.length > 0)
useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
if (flat.user.length > 0)
useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
...
}, ...);
```
当用户选择「导入到两者」时:
1. 调用 `replacePaths(TargetType.SYSTEM, ...)` → 创建一条 IMPORT undo 记录
2. 调用 `replacePaths(TargetType.USER, ...)` → 创建另一条 IMPORT undo 记录
用户按一次 Ctrl+Z 只能撤销一个 hive 的导入,需要按两次才能完全撤销。
### 修复方向
`app-store.ts` 中新增一个 `replaceBothPaths` 方法,将 system + user 的替换合并为一条 undo 记录,或者把 `replacePaths` 扩展为支持同时替换两个 hive。
---
## 非 Bug 问题(值得关注但暂不紧急)
| # | 位置 | 描述 |
|---|------|------|
| 1 | `PathTable.tsx:35-36` | `validatedRef` / `expandedRef` 随会话持续增长,不清理。Set 中的 key 是路径字符串,正常使用下数量有限(几十到几百),无实际性能影响 |
| 2 | `use-keyboard.ts:39-54` | 非管理员模式下 Ctrl+Z/Y/N/S/Delete 静默忽略,用户无任何反馈 |
| 3 | `app-store.ts:317` | `backup_registry` 失败时 set statusMessage 为警告,但若后续保存成功,`statusMessage` 被覆盖为「保存成功」,用户看不到备份失败的警告 |
| 4 | `MergePreview.tsx:57` | `key={${source}-${displayIndex}}` — key 由翻译后的 source 字符串和 displayIndex 拼接,理论上在极端场景(同一秒内两次渲染翻译变化)可能重复,实际概率极低 |
| 5 | `system.rs:44-45` | `ExpandEnvironmentStringsW` 返回 0 时只 `log::warn`,可通过 `GetLastError` 获取具体错误码来改进日志 |
| 6 | `disabled.rs:54-56` | `load_disabled_state``content.trim().is_empty()` 检查在 `serde_json::from_str` 之前,空文件返回空数组。但如果文件包含有效 JSON 空对象 `{}`,反序列化为 `DisabledState::default()` 也正确。逻辑完整 |
---
## 修复优先级建议
| 优先级 | Bug | 理由 |
|--------|-----|------|
| **P0** | BUG 1 — undo/redo 不持久化 disabled | 用户可感知的数据不一致 |
| **P1** | BUG 2 — expand_env_vars 截断 | 概率低但后果是静默数据损坏 |
| **P2** | BUG 3 — E2E mock 格式 | 不影响生产,但阻碍 E2E 测试扩展 |
| **P3** | BUG 4 — 双失败错误消息 | 边缘场景的 UX 问题 |
| **P3** | BUG 5 — 导入双 undo | 用户体验小瑕疵 |
+79
View File
@@ -0,0 +1,79 @@
# PathEditor v5.0 代码与架构审查报告
## 1. 项目概览
PathEditor v5.0 是一个功能完善的 Windows 系统环境变量 (PATH) 编辑器,支持 GUI 与 CLI 双模式。
技术栈选型现代化且合理:
- **后端 / 核心逻辑**Rust (Cargo Workspace)
- **GUI 框架**Tauri 2.x
- **前端**React 19 + TypeScript + Zustand
整体项目结构清晰,职责划分明确,严格遵循了前后端分离与核心逻辑无平台依赖的设计原则。
## 2. 架构设计审查
### 2.1 Cargo Workspace 三层架构
项目采用了经典的 Cargo Workspace 模式,分为三层:
- `core`: 纯 Rust 库 crate,包含所有的核心业务逻辑(注册表读写、备份、配置文件管理、路径验证与清理等)。该层**完全不依赖** Tauri 或 CLI 库,极大地提高了代码的复用性和可测试性。
- `gui`: Tauri 桌面应用。仅作为薄包装层(Thin Wrapper),通过 `#[tauri::command]``core` 的功能暴露为 IPC 接口供前端调用。
- `cli`: 命令行工具层。依赖 `core``clap` 库,直接提供命令行交互能力。
**审查结论**:架构设计非常优秀。核心逻辑解耦彻底,无论是 GUI 还是 CLI 都能复用同一套安全、经过测试的核心代码。
### 2.2 IPC 通信与状态同步
前端与 Rust 后端通过 Tauri IPC 进行通信。
- 所有的错误处理均通过 `Result<T, String>` 返回,前端通过 `Promise` 捕获并处理,用户体验良好。
- 针对非事务性的双写操作(如同时保存系统和用户 PATH),前端 `app-store.ts` 中使用了 `Promise.allSettled`。当发生部分成功(Partial Success)时,能正确捕获并重新加载注册表状态,避免了前端内存状态与后端注册表状态的漂移(State Drift)。
## 3. 后端代码审查 (Rust)
### 3.1 核心逻辑 (`core`)
- **安全性与健壮性**
-`registry.rs` 中,严格检查了路径字符串的 Null 字节,以及 32767 个字符的 Windows 注册表长度上限,防止缓冲区溢出或写入失败。
- 使用了安全的 `winreg` 库进行注册表操作。
- **FFI 调用**
-`system.rs` 中调用 Windows API(如 `ExpandEnvironmentStringsW``SendMessageTimeoutW`)时,对 `unsafe` 代码块进行了详尽的 SAFETY 注释。
- 能够妥善处理 UTF-16 编码和解码,保留非法码点避免丢失路径信息,细节处理非常到位。
### 3.2 命令行工具 (`cli`)
- **原子性与并发安全**
- 在 CLI 的 `verify_and_save` 逻辑中,写入前会重新读取注册表并与原始状态对比。如果不一致,则拒绝写入并报错退出。这有效地防止了并发情况下的配置覆盖问题。
- **用户体验**
- 命令设计符合直觉,支持 `--dry-run` 预览以及 JSON 格式输出,方便与其他脚本集成。
## 4. 前端代码审查 (React + TypeScript)
### 4.1 状态管理 (`app-store.ts`)
- 使用 `Zustand` 进行全局状态管理,状态树设计合理,避免了 React Context 可能带来的不必要重渲染。
- 实现了完善的 `UndoRedoManager`,将每一步操作抽象为 `OperationType`,支持撤销/重做功能,这对于编辑器类应用来说是核心体验的加分项。
- `isSaving` 状态守卫有效防止了用户双击保存按钮引发的并发竞争。
### 4.2 UI 与逻辑分离
- 业务逻辑抽象到 `src/core` 目录下(如 `path-manager.ts`, `validation.ts`),UI 组件仅负责渲染和事件绑定。
- `useAppActions.ts` 钩子巧妙地将组件层与 Store 状态操作解耦,使得组件代码极其整洁。
## 5. 改进建议 (Recommendations)
虽然当前代码质量已经很高,但仍有以下几个方面可以进一步优化:
1. **Rust FFI 维护性**
当前 `system.rs` 中手动声明了 `extern "system"` 函数。建议引入 `windows-rs``windows-sys` 库,这能提供微软官方维护的安全的 API 绑定,减少手动编写 FFI 签名带来的维护成本和潜在错误。
2. **GUI 保存的并发安全 (Race Condition)**
CLI 已经实现了保存前的二次状态比对(`verify_and_save`),但在 `gui/src/commands/registry.rs` 中,直接调用了 `save_system_paths`。如果在用户打开 GUI 修改期间,另一个进程修改了注册表,GUI 保存时可能会覆盖该修改。建议在 GUI 的 IPC 保存接口中,也引入类似 CLI 的版本校验(例如传入 `expected_original_paths` 进行比对)。
3. **前端单元测试覆盖**
核心逻辑如 `undo-redo.ts``path-manager.ts` 纯函数特性明显,建议在 `tests/unit/` 下增加对这些文件的边界用例测试,确保复杂编辑操作下状态不崩溃。
4. **长列表性能**
如果 PATH 环境变量条目非常多(虽然实际场景中一般在 100 条以内),React 渲染完整列表可能会有微小延迟。当前规模下无影响,但若未来考虑显示大量工具链路径扫描结果,可引入虚拟列表(Virtual List)。
## 总结
PathEditor v5.0 的代码库是一个优秀的 Rust + Tauri + React 实践范例。它具有清晰的三层架构、严格的类型和边界检查、以及良好的错误处理机制,整体架构稳健且易于长期维护。
+1
View File
@@ -0,0 +1 @@
# 截图目录
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

@@ -0,0 +1,212 @@
# v4.2 CI/CD 流水线 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 PathEditor 添加 GitHub Actions CI/CDpush 自动检查 + tag 自动构建发布
**Architecture:** 两个 workflow 文件。前端 job 跑 ubuntu(快),Rust job 跑 windowswinreg 依赖)。tag 推送触发 NSIS 构建上传。
**Tech Stack:** GitHub Actions, Windows runner, MinGW (MSYS2), Tauri CLI
---
### Task 1: 创建 CI workflow
**Files:**
- Create: `.github/workflows/ci.yml`
- [ ] **Step 1: 创建目录并写入 ci.yml**
```bash
mkdir -p .github/workflows
```
```yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches:
- '**'
tags-ignore:
- '**'
jobs:
frontend:
name: 前端检查 (TypeScript + Lint + Test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: TypeScript 类型检查
run: npx tsc --noEmit
- name: ESLint
run: npm run lint
- name: Vitest 测试
run: npm test
rust:
name: Rust 检查 (Check + Clippy + Test)
runs-on: windows-latest
defaults:
run:
working-directory: src-tauri
steps:
- uses: actions/checkout@v4
- name: 安装 GNU 工具链
run: |
rustup toolchain install stable-x86_64-pc-windows-gnu
rustup override set stable-x86_64-pc-windows-gnu
- name: 添加 MinGW 到 PATH
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Cargo Check
run: cargo check
- name: Cargo Clippy
run: cargo clippy -- -D warnings
- name: Cargo Test
run: cargo test
```
- [ ] **Step 2: 本地验证 YAML 语法**
```bash
# 可以用 Python 验证 YAML 语法(可选)
python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" 2>/dev/null || echo "跳过(无需本地验证,push 后 GitHub 自行检查)"
```
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/ci.yml
git commit -m "ci: 添加 CI workflow — push 自动检查 TypeScript + Rust
前端: tsc --noEmit + ESLint + Vitest (ubuntu)
Rust: cargo check + clippy + test (windows + GNU toolchain)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
### Task 2: 创建 Release workflow
**Files:**
- Create: `.github/workflows/release.yml`
- [ ] **Step 1: 写入 release.yml**
```yaml
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
name: 构建 NSIS 安装包并发布
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: 安装 GNU 工具链
run: |
rustup toolchain install stable-x86_64-pc-windows-gnu
rustup override set stable-x86_64-pc-windows-gnu
- name: 添加 MinGW 到 PATH
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Tauri Build
run: npx tauri build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: 上传安装包到 Release
run: |
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
env:
GH_TOKEN: ${{ github.token }}
```
- [ ] **Step 2: Commit**
```bash
git add .github/workflows/release.yml
git commit -m "ci: 添加 Release workflow — tag 推送自动构建 NSIS 安装包并发布
tag v* 触发 Tauri build,生成 NSIS 安装包后上传到 GitHub Release
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
### Task 3: 推送并验证
- [ ] **Step 1: 推送到 GitHub**
```bash
git push origin v4.2
```
- [ ] **Step 2: 查看 GitHub Actions**
打开 `https://github.com/LHY0125/PathEditor/actions`,确认 CI workflow 已触发并等待结果。
两个 job 应该都绿:
- `前端检查` — tsc + lint + vitest 通过
- `Rust 检查` — check + clippy + test 通过
- [ ] **Step 3: 验证 Release workflow(可选)**
推送一个测试 tag
```bash
git tag -a v4.2.0-beta -m "测试 CI release"
git push origin v4.2.0-beta
```
确认 `https://github.com/LHY0125/PathEditor/releases` 出现构建产物。测试完成后删除 tag:
```bash
git push origin --delete v4.2.0-beta
git tag -d v4.2.0-beta
gh release delete v4.2.0-beta --yes
```
---
## 注意事项
1. **TAURI_SIGNING_PRIVATE_KEY**: 如果项目签名配置了 Tauri updater 密钥,需要在 GitHub Settings → Secrets 中添加这两个 secret。如果当前没有配置 updater 签名,`tauri build` 会跳过签名步骤正常构建,但 CI 那一步会报找不到环境变量的警告。可以先不加这两个 secret,构建如果失败再加。
2. **首次运行**: GitHub Actions 在第一次 push `.github/workflows/` 后才会出现,之前需要等待。
3. **MinGW 路径**: `C:\msys64\mingw64\bin``windows-latest` runner 的固定路径。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
# v4.2 CI/CD 流水线 — 设计文档
**日期**: 2026-05-27
**分支**: v4.2
**状态**: 已确认
## 概述
为 PathEditor 添加 GitHub Actions CI/CD,实现 push 自动检查 + tag 自动构建发布。
## 触发策略
| 触发条件 | 做什么 |
|----------|--------|
| push 任意分支(不含 tag | 前端类型检查 + lint + 测试,Rust check + clippy + test |
| 推送 tag `v*` | Tauri 构建 NSIS 安装包,上传到 GitHub Release |
## Workflow 1: CI
**文件**: `.github/workflows/ci.yml`
两个并行 job
**frontend (ubuntu-latest)**:
- 用 ubuntu 而非 windows,更快且不依赖系统 API
- 步骤:checkout → setup-node → npm ci → tsc --noEmit → npm run lint → npm test
**rust (windows-latest)**:
- 必须用 windows`winreg` crate 依赖 Windows API
- 安装 GNU 工具链并 override,添加 MinGW bin 到 PATH
- 步骤:checkout → rustup toolchain install → override → PATH → cargo check → cargo clippy -- -D warnings → cargo test
## Workflow 2: Release
**文件**: `.github/workflows/release.yml`
单一 job `build-and-release` (windows-latest)
- checkout → setup-node → npm ci → rustup + MinGW → npx tauri build → gh release upload
构建产物:NSIS 安装包(`.exe`),上传到对应 tag 的 GitHub Release。
## MinGW 处理
- GitHub Actions `windows-latest` 自带 MSYS2MinGW 位于 `C:\msys64\mingw64\bin`
- `cargo test` 运行时需要 `libmcfgthread-2.dll`,将此路径加入 `PATH` 即可
## 范围限制
- 不做跨平台构建(项目仅面向 Windows)
- 不做覆盖率门槛
- Release 不重复跑 CItag 推送说明已通过 push 检查)
@@ -0,0 +1,90 @@
# v4.3 路径启用/禁用 + E2E 测试 — 设计文档
**日期**: 2026-05-27
**分支**: v4.2v4.3 后续创建)
**状态**: 已确认
## 概述
两项独立改进:
1. 路径启用/禁用(软开关):`string[]``PathEntry[]`,禁用状态存 JSON 文件
2. E2E 测试:Playwright + Mock IPC,覆盖 4 条关键流程
---
## Part 1: 路径启用/禁用
### 数据模型
```typescript
// src/core/path-entry.ts (新增)
export interface PathEntry {
path: string;
enabled: boolean;
}
```
全栈从 `string[]` 迁移到 `PathEntry[]`。注册表读写时做转换。
### 禁用状态持久化
文件:`%APPDATA%/PathEditor/disabled.json`
格式:`{ "system": ["path1"], "user": ["path2"] }`
加载流程:注册表读取 → `PathEntry[]`(全部 enabled:true)→ 读取 disabled.json → 匹配到的标记 enabled:false
保存流程:只将 enabled:true 的 path join 后写入注册表
切换流程:复选框点击 → 更新内存状态 → 立即调用 `save_disabled_state` 写入 JSON
### 新增 Rust 命令
- `save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String>`
- `load_disabled_state() -> Result<Vec<String>, Vec<String>, String>` 返回 `(system_disabled, user_disabled)`
### Undo 适配
- `OpRecord.oldPaths` / `newPaths``string[]` 改为 `PathEntry[]`
- 新增 `OperationType.TOGGLE = 8`:单条路径切换启用/禁用,undo 翻转回去
- 8 种已有操作类型的 switch case 适配 `PathEntry`
### UI
- 路径列表每行前加复选框,`#` 序号列与复选框合并
- 禁用行:灰色文字 + 删除线(`text-decoration: line-through`
- 工具栏不新增按钮(复选框独立操作)
### 状态层
- `app-store.ts` 新增 `togglePath(index, target)` 方法
- `loadPaths` 加载时合并 disabled 状态
- `savePaths` 保存时过滤 disabled 路径
---
## Part 2: E2E 测试
### 技术选型
`@playwright/test`,独立于 Vitest。Mock Tauri IPC 通过 `page.addInitScript()` 注入。
### Mock IPC
页面加载前注入 `window.__TAURI_INTERNALS__.invoke` 的 mock 实现,按命令名返回假数据。
### 4 条测试场景
| 场景 | 步骤 |
|------|------|
| 启动加载 | 访问页面 → 系统 PATH 显示 2 条 → 用户 PATH 显示 1 条 |
| CRUD + 撤销 | 添加路径 → 出现在列表 → Ctrl+Z 撤销 → 路径消失 → Ctrl+Y 重做 → 路径恢复 |
| 禁用 + 保存 | 点击复选框禁用 → 路径灰显+删除线 → 点保存 → 验证 save IPC 只传了 enabled 路径 |
| 搜索 + 清理 | 输入搜索词 → 列表过滤 → 清空搜索 → 点清理 → 无效路径红色 |
### 运行方式
```bash
npx playwright test
```
需要先启动 Vite 开发服务器:`npm run dev`
+5
View File
@@ -0,0 +1,5 @@
interface Window {
__TAURI_INTERNALS__?: {
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
};
}
+31
View File
@@ -0,0 +1,31 @@
export function createIpcMock() {
return `
window.__TAURI_INTERNALS__ = {
invoke: async (cmd, args) => {
switch (cmd) {
case 'check_admin': return true;
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
case 'load_disabled_state': return [[], []];
case 'save_system_paths': return undefined;
case 'save_user_paths': return undefined;
case 'save_disabled_state': return undefined;
case 'backup_registry': return 'C:\\\\backup\\\\path.txt';
case 'broadcast_env_change': return undefined;
case 'validate_path': return true;
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);
}
}
};
`;
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 10000,
use: {
baseURL: 'http://localhost:5173',
locale: 'zh-CN',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
},
});
+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();
});
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
});
test('添加路径后可撤销和重做', async ({ page }) => {
// 点击"新建"按钮
await page.click('text=新建');
// 填入路径(对话框内自动聚焦的 input)
await page.locator('.fixed.inset-0 input[type="text"]').fill('C:\\\\NewPath');
// 对话框确认按钮是"确认"
await page.click('text=确认');
// 路径应出现在列表中
await page.waitForTimeout(300);
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
// Ctrl+Z 撤销
await page.keyboard.press('Control+z');
await page.waitForTimeout(300);
await expect(page.locator('text=C:\\\\NewPath')).not.toBeVisible();
// Ctrl+Y 重做
await page.keyboard.press('Control+y');
await page.waitForTimeout(300);
await expect(page.locator('text=C:\\\\NewPath')).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();
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.__TAURI_INTERNALS__ = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
invoke: async (cmd, args) => {
switch (cmd) {
case 'check_admin':
return true;
case 'load_system_paths':
return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
case 'load_user_paths':
return [];
case 'load_disabled_state':
return { system: [], user: [] };
case 'save_system_paths':
return undefined;
case 'save_user_paths':
return undefined;
case 'save_disabled_state':
return undefined;
case 'backup_registry':
return '';
case 'broadcast_env_change':
return undefined;
case 'validate_path':
return false;
case 'expand_env_vars':
return '';
case 'read_text_file':
return '';
case 'get_appdata_dir':
return '';
default:
return undefined;
}
},
};
});
await page.goto('/');
});
test('搜索过滤后清理无效路径', async ({ page }) => {
// 初始 3 条路径
await page.waitForTimeout(500);
await expect(page.locator('table tbody tr')).toHaveCount(3);
// 搜索 "Windows"
const searchInput = page.locator('input[placeholder]');
await searchInput.fill('Windows');
await page.waitForTimeout(300);
await expect(page.locator('table tbody tr')).toHaveCount(1);
// 清除搜索
await searchInput.fill('');
await page.waitForTimeout(300);
await expect(page.locator('table tbody tr')).toHaveCount(3);
// 点击"一键清理"按钮
await page.click('text=一键清理');
await page.waitForTimeout(300);
// is_valid_path_format 只校验格式,不检查存在性
// "invalid_path" 格式无效被移除,C:\Windows 和 C:\Temp 格式有效保留
await expect(page.locator('table tbody tr')).toHaveCount(2);
});
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
});
test('启动后加载系统 PATH 和用户 PATH', async ({ page }) => {
// 系统 tab 默认激活,显示 2 条路径
await expect(page.locator('table tbody tr')).toHaveCount(2);
// 切换到用户 tab
await page.click('text=用户变量');
await page.waitForTimeout(300);
await expect(page.locator('table tbody tr')).toHaveCount(1);
});
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock());
await page.goto('/');
});
test('禁用路径后灰显并保存', async ({ page }) => {
// 点击第一个路径的 checkbox 将其禁用
const checkbox = page.locator('table tbody tr').first().locator('input[type="checkbox"]');
await checkbox.click();
await page.waitForTimeout(300);
// 路径文本应有删除线样式(第 3 列是路径列,nth(2) 即 0-indexed 第 3 个 td
const row = page.locator('table tbody tr').first();
await expect(row.locator('td').nth(2)).toHaveCSS('text-decoration-line', 'line-through');
// 点击"确定"保存
await page.click('text=确定');
await page.waitForTimeout(500);
// 状态栏应显示"保存成功"
await expect(page.locator('text=保存成功')).toBeVisible();
});
+24
View File
@@ -0,0 +1,24 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist', 'gui'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);
+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.0.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) }
@@ -1,4 +1,7 @@
pub mod registry;
pub mod system;
pub mod backup;
pub mod disabled;
pub mod fs;
pub mod profiles;
pub mod registry;
pub mod scanner;
pub mod system;
+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() }
+9
View File
@@ -26,6 +26,15 @@ pub fn run() {
commands::backup::backup_registry,
commands::backup::get_appdata_dir,
commands::fs::read_text_file,
commands::disabled::save_disabled_state,
commands::disabled::load_disabled_state,
commands::scanner::scan_conflicts,
commands::scanner::scan_tools,
commands::profiles::list_profiles,
commands::profiles::save_profile,
commands::profiles::load_profile,
commands::profiles::delete_profile,
commands::profiles::rename_profile,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "PathEditor",
"version": "4.0.0",
"version": "5.0.0",
"identifier": "com.liuhangyu.patheditor",
"build": {
"frontendDist": "../dist",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "PathEditor v4.0",
"title": "PathEditor v5.0",
"width": 900,
"height": 700,
"minWidth": 800,
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PathEditor v4.0</title>
<title>PathEditor v5.0</title>
</head>
<body>
<div id="root"></div>
+2350 -48
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -1,15 +1,29 @@
{
"name": "patheditor",
"private": true,
"version": "4.0.0",
"version": "5.0.0",
"type": "module",
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"*.{json,md,css,html}": [
"prettier --write"
]
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --config e2e/playwright.config.ts",
"prepare": "husky"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
@@ -24,16 +38,26 @@
"zustand": "^5.0.13"
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@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",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.4.0",
"prettier": "^3.8.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",

Some files were not shown because too many files have changed in this diff Show More