Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5f3f5750c | |||
| 8c0e80d862 | |||
| 5c73321ce6 | |||
| 44cd6c6595 | |||
| 44a4a4ccf3 | |||
| dc36d63302 | |||
| 6822ab9f3e | |||
| 21af2683ac | |||
| 7aa5dcd832 | |||
| 9b5b57a3ac | |||
| 1320aa57a8 | |||
| a553a16a64 | |||
| c181fe15d4 | |||
| 36e1c89b2e | |||
| 812f39b159 | |||
| cd896d389b | |||
| 5a864c41b2 |
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
test-results/
|
||||
e2e/
|
||||
*.config.*
|
||||
@@ -0,0 +1,32 @@
|
||||
# Git 行尾符规范化
|
||||
# 本仓库统一 CRLF(Windows 原生项目)
|
||||
|
||||
# 源码文本文件
|
||||
*.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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# 开源赞助
|
||||
# 支持 PathEditor 的开发
|
||||
|
||||
github: LHY0125
|
||||
# 如需定制功能或商业授权,请通过 GitHub Issues 联系
|
||||
@@ -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 改动,请附上前后对比截图 -->
|
||||
@@ -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)"
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: 前端检查 (TypeScript + Lint + Test)
|
||||
name: 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -24,24 +24,35 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Prettier 格式检查
|
||||
run: npx prettier --check "src/**/*.{ts,tsx}" "tests/**/*.{ts,tsx}" "e2e/**/*.ts"
|
||||
|
||||
- name: TypeScript 类型检查
|
||||
run: npx tsc --noEmit
|
||||
run: npx tsc -b --noEmit
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
run: npx eslint src/ tests/
|
||||
|
||||
- name: Vitest 测试
|
||||
run: npm test
|
||||
- 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)
|
||||
name: Rust 检查 (格式 + Check + Clippy + Test)
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cargo Format
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Cargo Check
|
||||
run: cargo check
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: 上传安装包到 Release
|
||||
run: |
|
||||
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
|
||||
$installer = Get-ChildItem -Path "target\release\bundle\nsis\*.exe" | Select-Object -First 1
|
||||
if (gh release view $env:GITHUB_REF_NAME 2>$null) {
|
||||
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
|
||||
} else {
|
||||
|
||||
@@ -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,7 +35,16 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# AI assistant
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Platform
|
||||
e2e/debug-screenshot.png
|
||||
test-results/
|
||||
target/
|
||||
|
||||
# Archive
|
||||
*.zip
|
||||
*.7z
|
||||
*.tar.gz
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": {
|
||||
"allowed_elements": ["img", "br", "kbd", "summary", "details"]
|
||||
},
|
||||
"MD041": false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
target
|
||||
*.local
|
||||
*.log
|
||||
test-results
|
||||
coverage
|
||||
Cargo.lock
|
||||
package-lock.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "crlf",
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -1,77 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## [4.2.0] — 2026-05-28
|
||||
## 5.0.0 (2026-05-29)
|
||||
|
||||
### 新增
|
||||
- 路径启用/禁用功能:复选框控制 PATH 中每条路径是否生效
|
||||
- PathEntry 数据类型:替代原有 `string[]`,支持 `enabled` 状态
|
||||
- `disabled.json` 持久化禁用状态的独立存储
|
||||
- E2E 测试框架:Playwright + 4 条核心流程测试
|
||||
- CI/CD 流水线:TypeScript + Rust 自动检查,Release 自动构建
|
||||
### Added
|
||||
- Cargo workspace 三层架构 (core + gui + cli)
|
||||
- CLI 命令行工具,17 条命令,支持 JSON 输出
|
||||
- PATH 可执行文件冲突检测 (`scan_conflicts`)
|
||||
- PATH 目录工具清单 (`scan_tools`)
|
||||
- 配置文件管理:保存/加载/应用/重命名/删除
|
||||
- 系统+用户合并预览视图
|
||||
- CLI 原子性保护:写入前重新读取注册表对比
|
||||
- `--steps N` 参数支持多格移动 (CLI 特有)
|
||||
|
||||
### 修复
|
||||
- undo/redo after toggle 未持久化 disabled 状态
|
||||
- expand_env_vars 两次 API 调用间缓冲区截断风险
|
||||
- E2E mock load_disabled_state 返回格式与 Rust 后端不匹配
|
||||
- 双 hive 保存失败时错误信息只显示一个
|
||||
- 导入 both 产生两条 undo 记录,需两次 Ctrl+Z
|
||||
- 备份失败警告被"保存成功"覆盖
|
||||
- 非连续多行删除后 undo 恢复到错误位置
|
||||
- backup_registry 未 await 导致竞态保存新值
|
||||
### Changed
|
||||
- Rust + Tauri 2.x + React 19 + TypeScript strict 全重写
|
||||
- 撤销/重做系统扩展至 10 种操作类型
|
||||
- 禁用状态即时持久化,不依赖保存按钮
|
||||
- 深色模式 / 浅色模式 CSS 变量驱动
|
||||
- 中英双语界面 (i18next)
|
||||
- 备份文件存储路径统一到 `~/.patheditor/`
|
||||
- 版本号集中管理: Rust 端 `Cargo.toml` workspace, 前端 `package.json`
|
||||
|
||||
### 变更
|
||||
- 导入改用原生文件对话框(`@tauri-apps/plugin-dialog`)
|
||||
- PathTable 环境变量展开限流 20 并发
|
||||
- CI 切换到 MSVC 工具链
|
||||
- 版本号统一为 4.2.0
|
||||
### Fixed
|
||||
- 非管理员自动进入只读模式
|
||||
- 保存失败精确提示哪个注册表 hive 出错 (Promise.allSettled)
|
||||
- CLI `--system`/`--user` 互斥校验
|
||||
- 修改操作后广播 `WM_SETTINGCHANGE`
|
||||
- 深色模式下行选中颜色对比度不足
|
||||
- 窗口内容溢出无法滚动
|
||||
|
||||
---
|
||||
## 4.2.0
|
||||
|
||||
## [4.1.0] — 2026-05-26
|
||||
### Fixed
|
||||
- Release workflow 兼容已存在的 release
|
||||
|
||||
### 新增
|
||||
- app-store 单元测试:25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths
|
||||
- 72 个前端单元测试 + 10 个 Rust 单元测试
|
||||
## 4.1.0
|
||||
|
||||
### 修复
|
||||
- NSIS 安装包缺少 WebView2Loader.dll
|
||||
- AppShell overflow-hidden 导致窗口无法上下滚动
|
||||
### Added
|
||||
- 路径验证 (红色无效、橙色重复)
|
||||
- 环境变量路径悬浮展开预览
|
||||
- 全局键盘快捷键
|
||||
- 修改状态指示 + 未保存退出确认
|
||||
|
||||
### 变更
|
||||
- 清理 LOW 问题:样式去重、死代码删除、命名修正
|
||||
- 抽取 Modal 共享组件、统一按钮样式
|
||||
- 支持 JSON/CSV/TXT 三种导入导出格式
|
||||
## 4.0.0
|
||||
|
||||
---
|
||||
|
||||
## [4.0.0] — 2026-05-25
|
||||
|
||||
### 重大变更
|
||||
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
|
||||
|
||||
### 新增
|
||||
- 现代 Web UI(React 19 + Tailwind CSS 4 + Zustand)
|
||||
- 深色/浅色模式切换
|
||||
- 中英文界面即时切换
|
||||
- 路径有效性颜色编码(红色无效、橙色重复)
|
||||
- 环境变量展开悬停提示
|
||||
- 文件夹拖拽添加路径
|
||||
- 保存前 PATH 长度检查
|
||||
|
||||
### 改进
|
||||
- 完整撤销/重做支持(8 种操作类型,50 步历史)
|
||||
- JSON/CSV/TXT 三种格式导入导出
|
||||
- 合并预览查看系统+用户路径
|
||||
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
|
||||
- NSIS 安装包,约 8MB
|
||||
|
||||
### 移除
|
||||
- 旧 C + IUP + Lua + gettext 代码库
|
||||
- Lua 配置引擎 → JSON 配置文件
|
||||
- gettext 国际化 → i18next
|
||||
|
||||
---
|
||||
|
||||
## [3.x] 及更早
|
||||
|
||||
C + IUP GUI 版本,已停止维护。历史发布记录见 [GitHub Releases](https://github.com/LHY0125/PathEditor/releases)。
|
||||
### Added
|
||||
- Tauri 2.x + React + TypeScript 首次发布
|
||||
- Windows 系统/用户 PATH 的增删改查
|
||||
- 拖拽排序、多选批量删除
|
||||
- 实时搜索过滤
|
||||
- 导入导出 JSON/CSV/TXT
|
||||
- 撤销/重做支持
|
||||
- 保存前自动备份注册表
|
||||
|
||||
@@ -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` | 前端动态 import(TitleBar、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,**不要**覆盖旧版本
|
||||
@@ -0,0 +1,36 @@
|
||||
# 贡献者行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
为了营造一个开放和友好的环境,我们作为贡献者和维护者承诺:无论年龄、体型、残障、种族、性别认同和表达、经验水平、国籍、个人外貌、宗教、性取向或身份,参与本项目不会受到骚扰。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于创造积极环境的行为包括:
|
||||
|
||||
- 使用友好和包容的语言
|
||||
- 尊重不同的观点和经验
|
||||
- 优雅地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
- 对其他社区成员表示同理心
|
||||
|
||||
不可接受的行为包括:
|
||||
|
||||
- 使用性暗示语言或图像以及不受欢迎的性关注
|
||||
- 侮辱/贬损性评论以及人身攻击或政治攻击
|
||||
- 公开或私下的骚扰
|
||||
- 未经明确许可发布他人的私人信息
|
||||
|
||||
## 我们的责任
|
||||
|
||||
项目维护者有责任澄清可接受行为的标准,并应对任何不可接受的行为采取适当和公平的纠正措施。
|
||||
|
||||
## 范围
|
||||
|
||||
本行为准则适用于项目空间和代表项目的公共空间。
|
||||
|
||||
## 执行
|
||||
|
||||
可通过 GitHub Issues 或直接联系维护者报告辱骂、骚扰或其他不可接受的行为。所有投诉将被审查和调查,并将产生被认为必要且适合情况的回应。
|
||||
|
||||
本项目改编自 [Contributor Covenant](https://www.contributor-covenant.org) 2.1 版。
|
||||
@@ -1,29 +1,74 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 PathEditor 的关注!
|
||||
## 本地开发环境
|
||||
|
||||
## 提交 Issue
|
||||
- **Node.js** 22+
|
||||
- **Rust** 1.95+ (stable-x86_64-pc-windows-gnu)
|
||||
- **MinGW-w64** (GCC 15.x 需 `-lmcfgthread` 链接标志)
|
||||
- **Windows 10+** (自带 WebView2)
|
||||
|
||||
- 使用清晰的标题描述问题
|
||||
- 提供复现步骤
|
||||
- 附上系统信息(Windows 版本、是否管理员)
|
||||
- 如果是功能建议,说明使用场景
|
||||
## 开发流程
|
||||
|
||||
## 提交 Pull Request
|
||||
1. Fork 本仓库
|
||||
2. `git clone <你的 fork>`
|
||||
3. `git checkout -b feature/xxx`
|
||||
4. 开发 + 测试
|
||||
5. `git commit` (遵循约定式提交格式)
|
||||
6. `git push`
|
||||
7. 提交 Pull Request
|
||||
|
||||
1. Fork 仓库并从 `main` 创建功能分支
|
||||
2. 运行 `npm test` 和 `cargo check` 确保通过
|
||||
3. 遵循项目代码规范:
|
||||
- TypeScript `strict: true`,零编译错误
|
||||
- 前端核心逻辑在 `src/core/`,纯函数,零依赖
|
||||
- Rust `unsafe` 块必须有 `// SAFETY:` 注释
|
||||
4. 新功能应包含测试
|
||||
|
||||
## 本地开发
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx tauri dev
|
||||
# 前端单元测试
|
||||
npm test
|
||||
|
||||
# Rust 测试
|
||||
cargo test
|
||||
|
||||
# E2E 测试 (需要先 npm run dev)
|
||||
npx playwright test
|
||||
|
||||
# Clippy 检查
|
||||
cargo clippy -- -D warnings
|
||||
```
|
||||
|
||||
详见 [README.md](./README.md#开发)。
|
||||
## 代码规范
|
||||
|
||||
### TypeScript
|
||||
|
||||
- `strict: true`,零编译错误
|
||||
- 核心逻辑在 `src/core/`,纯函数,零框架依赖
|
||||
- 不可变操作优先
|
||||
|
||||
### Rust
|
||||
|
||||
- 所有 `pub fn` 必须有 `///` 文档注释
|
||||
- 所有 `unsafe` 块必须有 `// SAFETY:` 注释
|
||||
- `cargo clippy -- -D warnings` 零警告
|
||||
- `cargo fmt` 统一格式
|
||||
|
||||
## 提交格式
|
||||
|
||||
```
|
||||
<类型>: <描述>
|
||||
```
|
||||
|
||||
类型:`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
core/ # Rust 核心库(零 Tauri 依赖)
|
||||
gui/ # Tauri 桌面应用
|
||||
cli/ # 命令行工具
|
||||
src/ # React 前端
|
||||
tests/unit/ # 前端单元测试
|
||||
e2e/ # Playwright E2E 测试
|
||||
```
|
||||
|
||||
## 开始贡献前
|
||||
|
||||
- 大改动建议先开 Issue 讨论
|
||||
- 新功能需要对应的测试
|
||||
- 不要引入新的 clippy 警告
|
||||
|
||||
@@ -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"
|
||||
@@ -4,23 +4,45 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-4.3.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-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>
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
### 主界面
|
||||
|
||||

|
||||
|
||||
### 路径编辑
|
||||
|
||||

|
||||
|
||||
### 冲突检测
|
||||
|
||||

|
||||
|
||||
### CLI 命令行
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 简介
|
||||
|
||||
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
|
||||
|
||||
v4.3 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
|
||||
v5.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -35,15 +57,21 @@ graph TB
|
||||
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 后端"]
|
||||
subgraph 后端["Rust core 库"]
|
||||
Registry[注册表读写<br/>HKLM / HKCU]
|
||||
System[系统操作<br/>权限检测 / 路径验证 / 环境变量展开]
|
||||
Files[文件操作<br/>备份 / 配置 / 导入读取]
|
||||
Files[文件操作<br/>备份 / 配置 / 导入导出]
|
||||
Scanner[分析引擎<br/>冲突检测 / 工具清单]
|
||||
Profiles[配置管理<br/>save/load/apply/rename]
|
||||
end
|
||||
|
||||
subgraph Windows["Windows 系统"]
|
||||
@@ -56,10 +84,18 @@ graph TB
|
||||
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
|
||||
```
|
||||
|
||||
### 组件树
|
||||
@@ -109,9 +145,56 @@ sequenceDiagram
|
||||
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)
|
||||
- 新建、编辑、删除、上移、下移路径条目
|
||||
- 多选批量删除
|
||||
@@ -120,26 +203,31 @@ sequenceDiagram
|
||||
- 文件夹拖拽添加
|
||||
|
||||
### 路径验证
|
||||
|
||||
- **红色**标记:路径在文件系统中不存在
|
||||
- **橙色**标记:路径在列表中重复出现
|
||||
- 环境变量路径(含 `%VAR%`)悬浮展开预览
|
||||
|
||||
### 撤销/重做
|
||||
|
||||
- 支持 9 种操作类型,最多 50 步历史
|
||||
- 新增、删除、编辑、移动、清理、清空、导入均可撤销
|
||||
|
||||
### 导入/导出
|
||||
|
||||
- **JSON**:结构化导出,含版本和时间戳
|
||||
- **CSV**:UTF-8 BOM 编码,兼容 Excel
|
||||
- **TXT**:纯文本,每行一个路径
|
||||
|
||||
### 安全
|
||||
|
||||
- 保存前自动备份注册表到 `%APPDATA%/PathEditor/backups/`
|
||||
- PATH 长度检查(Windows 单变量上限 32767 字符)
|
||||
- 非管理员自动进入**只读模式**
|
||||
- 保存中途失败精确提示哪个注册表 hive 出错
|
||||
|
||||
### 界面
|
||||
|
||||
- 深色模式 / 浅色模式
|
||||
- 中文 / English 界面切换
|
||||
- 全局键盘快捷键
|
||||
@@ -147,7 +235,7 @@ sequenceDiagram
|
||||
|
||||
## 安装
|
||||
|
||||
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.2.0_x64-setup.exe` 安装。
|
||||
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_5.0.0_x64-setup.exe` 安装。
|
||||
|
||||
或从源码构建:
|
||||
|
||||
@@ -164,7 +252,7 @@ npx tauri build
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 开发模式(热更新)
|
||||
# 开发模式 GUI(热更新)
|
||||
npx tauri dev
|
||||
|
||||
# 仅前端
|
||||
@@ -173,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 (72 个测试) |
|
||||
| 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` | 帮助 |
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
@@ -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) 提交功能建议!
|
||||
@@ -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/` 目录设为仅当前用户可读。
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
},
|
||||
/// 从文件导入 PATH(JSON/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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "path-editor-core"
|
||||
description = "PathEditor core library — shared between GUI and CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
winreg = "0.52"
|
||||
dirs = "5"
|
||||
chrono = "0.4"
|
||||
@@ -1,25 +1,22 @@
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
use winreg::enums::*;
|
||||
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
|
||||
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
|
||||
|
||||
fn backup_base_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("PathEditor")
|
||||
.join(".patheditor")
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
/// 获取 APPDATA 路径下的备份目录
|
||||
#[tauri::command]
|
||||
/// 获取备份目录路径
|
||||
pub fn get_appdata_dir() -> String {
|
||||
backup_base_dir().to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
||||
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
|
||||
#[tauri::command]
|
||||
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
||||
let backup_dir = match custom_dir {
|
||||
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
|
||||
@@ -66,3 +63,33 @@ pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
||||
log::info!("备份已保存到: {}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn get_appdata_dir_returns_non_empty() {
|
||||
assert!(!get_appdata_dir().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_registry_with_custom_dir() {
|
||||
let dir = std::env::temp_dir().join("patheditor_test_backup_custom");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let result = backup_registry(Some(dir.to_string_lossy().to_string()));
|
||||
// 可能因无权限读取注册表而失败,但不应 panic
|
||||
if let Ok(path) = result {
|
||||
assert!(path.contains("patheditor_test_backup_custom"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_registry_default_dir_no_panic() {
|
||||
// 验证不传参时不会 panic
|
||||
let _ = backup_registry(None);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn disabled_file_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("PathEditor")
|
||||
.join(".patheditor")
|
||||
.join("disabled.json")
|
||||
}
|
||||
|
||||
@@ -19,7 +18,6 @@ struct DisabledState {
|
||||
}
|
||||
|
||||
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
|
||||
#[tauri::command]
|
||||
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
|
||||
let state = DisabledState { system, user };
|
||||
let path = disabled_file_path();
|
||||
@@ -40,7 +38,6 @@ pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(),
|
||||
}
|
||||
|
||||
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
|
||||
#[tauri::command]
|
||||
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
||||
let path = disabled_file_path();
|
||||
|
||||
@@ -60,3 +57,30 @@ pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
||||
|
||||
Ok((state.system, state.user))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_state() {
|
||||
// roundtrip
|
||||
let sys = vec!["C:\\sys1".into(), "C:\\sys2".into()];
|
||||
let usr = vec!["D:\\usr1".into()];
|
||||
save_disabled_state(sys.clone(), usr.clone()).unwrap();
|
||||
let (loaded_sys, loaded_usr) = load_disabled_state().unwrap();
|
||||
assert_eq!(loaded_sys, sys);
|
||||
assert_eq!(loaded_usr, usr);
|
||||
|
||||
// overwrite
|
||||
let new_sys = vec!["C:\\new".into()];
|
||||
save_disabled_state(new_sys.clone(), vec![]).unwrap();
|
||||
let (loaded, _) = load_disabled_state().unwrap();
|
||||
assert_eq!(loaded, new_sys);
|
||||
|
||||
// empty
|
||||
save_disabled_state(vec![], vec![]).unwrap();
|
||||
let result = load_disabled_state().unwrap();
|
||||
assert!(result.0.is_empty() && result.1.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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::*;
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct ConflictLocation {
|
||||
pub dir: String,
|
||||
pub priority: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct ConflictEntry {
|
||||
pub name: String,
|
||||
pub locations: Vec<ConflictLocation>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ToolGroup {
|
||||
pub dir: String,
|
||||
pub exists: bool,
|
||||
pub exes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 扫描单个目录中的可执行文件名
|
||||
fn list_exes(dir: &str) -> Vec<String> {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
return vec![];
|
||||
}
|
||||
let mut exes: Vec<String> = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(p) {
|
||||
for entry in entries.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let name = fname.to_string_lossy();
|
||||
if let Some(ext) = Path::new(name.as_ref()).extension() {
|
||||
let ext_lower = ext.to_ascii_lowercase();
|
||||
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
|
||||
exes.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
exes
|
||||
}
|
||||
|
||||
/// 扫描 PATH 中的可执行文件冲突
|
||||
///
|
||||
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
|
||||
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
|
||||
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
|
||||
// 并行扫描各目录
|
||||
let results: Vec<(usize, String, Vec<String>)> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = paths.iter().enumerate().map(|(priority, dir)| {
|
||||
s.spawn(move || (priority, dir.clone(), list_exes(dir)))
|
||||
}).collect();
|
||||
handles.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
|
||||
// 合并: exe_name (小写) → [(priority, dir)]
|
||||
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
|
||||
for (priority, dir, exes) in results {
|
||||
for name in exes {
|
||||
map.entry(name.to_lowercase())
|
||||
.or_default()
|
||||
.push((priority, dir.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut results: Vec<ConflictEntry> = map
|
||||
.into_iter()
|
||||
.filter(|(_, locs)| locs.len() >= 2)
|
||||
.map(|(name, locs)| ConflictEntry {
|
||||
name,
|
||||
locations: locs
|
||||
.into_iter()
|
||||
.map(|(priority, dir)| ConflictLocation { dir, priority })
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 扫描 PATH 中各目录提供的可执行文件
|
||||
///
|
||||
/// query 非空时只返回文件名包含关键词的结果。各目录并行扫描。
|
||||
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// 并行扫描各目录
|
||||
let dir_results: Vec<(String, Option<Vec<String>>)> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = paths.iter().map(|dir| {
|
||||
s.spawn(move || {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
return (dir.clone(), None);
|
||||
}
|
||||
let exes = list_exes(dir);
|
||||
(dir.clone(), Some(exes))
|
||||
})
|
||||
}).collect();
|
||||
handles.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
|
||||
let mut groups: Vec<ToolGroup> = Vec::new();
|
||||
for (dir, opt_exes) in dir_results {
|
||||
match opt_exes {
|
||||
None => {
|
||||
groups.push(ToolGroup { dir, exists: false, exes: vec![] });
|
||||
}
|
||||
Some(mut exes) => {
|
||||
if !query_lower.is_empty() {
|
||||
exes.retain(|name| name.to_lowercase().contains(&query_lower));
|
||||
}
|
||||
exes.sort();
|
||||
groups.push(ToolGroup { dir, exists: true, exes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("patheditor_test_{}", prefix));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for name in exe_names {
|
||||
fs::write(dir.join(name), b"fake").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_conflicts_no_duplicates() {
|
||||
let d1 = make_temp_dir_with_exes("c_a", &["a.exe"]);
|
||||
let d2 = make_temp_dir_with_exes("c_b", &["b.exe"]);
|
||||
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
|
||||
let conflicts = scan_conflicts(paths).unwrap();
|
||||
assert!(conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_conflicts_detects_duplicate() {
|
||||
let d1 = make_temp_dir_with_exes("c_dup1", &["shared.exe"]);
|
||||
let d2 = make_temp_dir_with_exes("c_dup2", &["shared.exe"]);
|
||||
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
|
||||
let conflicts = scan_conflicts(paths).unwrap();
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].locations.len(), 2);
|
||||
assert_eq!(conflicts[0].locations[0].priority, 0);
|
||||
assert_eq!(conflicts[0].locations[1].priority, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tools_returns_groups() {
|
||||
let d1 = make_temp_dir_with_exes("t_a", &["tool.exe", "helper.bat"]);
|
||||
let paths = vec![d1.to_string_lossy().to_string()];
|
||||
let groups = scan_tools(paths, String::new()).unwrap();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert!(groups[0].exists);
|
||||
assert!(groups[0].exes.contains(&"helper.bat".to_string()));
|
||||
assert!(groups[0].exes.contains(&"tool.exe".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tools_with_query_filters() {
|
||||
let d1 = make_temp_dir_with_exes("t_q", &["apple.exe", "banana.exe"]);
|
||||
let paths = vec![d1.to_string_lossy().to_string()];
|
||||
let groups = scan_tools(paths, "apple".into()).unwrap();
|
||||
assert_eq!(groups[0].exes.len(), 1);
|
||||
assert_eq!(groups[0].exes[0], "apple.exe");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
|
||||
#[tauri::command]
|
||||
/// 检测当前进程是否有管理员权限
|
||||
///
|
||||
/// 通过尝试以写入权限打开系统 PATH 注册表键判断。
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` 表示有管理员权限,`false` 为只读模式
|
||||
pub fn check_admin() -> bool {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
hklm.open_subkey_with_flags(
|
||||
@@ -14,7 +18,6 @@ pub fn check_admin() -> bool {
|
||||
|
||||
/// 验证路径是否存在于文件系统中(且是目录)
|
||||
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
|
||||
#[tauri::command]
|
||||
pub fn validate_path(path: &str) -> bool {
|
||||
if path.contains('%') {
|
||||
return true;
|
||||
@@ -23,7 +26,6 @@ pub fn validate_path(path: &str) -> bool {
|
||||
}
|
||||
|
||||
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin)
|
||||
#[tauri::command]
|
||||
pub fn expand_env_vars(path: &str) -> String {
|
||||
if !path.contains('%') {
|
||||
return path.to_string();
|
||||
@@ -58,13 +60,23 @@ pub fn expand_env_vars(path: &str) -> String {
|
||||
return path.to_string();
|
||||
}
|
||||
|
||||
// 转回 UTF-8 (去掉结尾 null)
|
||||
// 转回 UTF-8 (去掉结尾 null),保留非法码点避免丢失路径信息
|
||||
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
|
||||
String::from_utf16_lossy(&buffer[..len])
|
||||
decode_utf16_preserving(&buffer[..len])
|
||||
}
|
||||
|
||||
/// 解码 UTF-16 为 String,非法码点编码为 \u{XXXX} 而非静默丢弃
|
||||
fn decode_utf16_preserving(v: &[u16]) -> String {
|
||||
char::decode_utf16(v.iter().copied())
|
||||
.map(|r| match r {
|
||||
Ok(c) => c.to_string(),
|
||||
Err(e) => format!("\\u{{{:X}}}", e.unpaired_surrogate()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 广播环境变量更改通知(WM_SETTINGCHANGE)
|
||||
#[tauri::command]
|
||||
/// 广播 `WM_SETTINGCHANGE` 通知系统环境变量已变更
|
||||
pub fn broadcast_env_change() {
|
||||
const HWND_BROADCAST: isize = 0xFFFF;
|
||||
const WM_SETTINGCHANGE: u32 = 0x001A;
|
||||
@@ -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 代码审查_
|
||||
@@ -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 实践范例。它具有清晰的三层架构、严格的类型和边界检查、以及良好的错误处理机制,整体架构稳健且易于长期维护。
|
||||
@@ -0,0 +1 @@
|
||||
# 截图目录
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,5 @@
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: {
|
||||
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,13 @@ export function createIpcMock() {
|
||||
case 'expand_env_vars': return 'C:\\\\Expanded';
|
||||
case 'read_text_file': return '';
|
||||
case 'get_appdata_dir': return 'C:\\\\appdata';
|
||||
case 'scan_conflicts': return [];
|
||||
case 'scan_tools': return [];
|
||||
case 'list_profiles': return [];
|
||||
case 'save_profile': return undefined;
|
||||
case 'load_profile': return null;
|
||||
case 'delete_profile': return undefined;
|
||||
case 'rename_profile': return undefined;
|
||||
default: throw new Error('Unexpected invoke: ' + cmd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -6,22 +6,36 @@ test.beforeEach(async ({ page }) => {
|
||||
// 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;
|
||||
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('/');
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src-tauri'] },
|
||||
{ ignores: ['dist', 'gui'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -2271,7 +2271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patheditor"
|
||||
version = "4.0.0"
|
||||
version = "5.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "patheditor"
|
||||
version = "4.3.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"
|
||||
|
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 |
@@ -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() }
|
||||
@@ -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() }
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,10 @@
|
||||
use path_editor_core::system;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_admin() -> bool { system::check_admin() }
|
||||
#[tauri::command]
|
||||
pub fn validate_path(path: &str) -> bool { system::validate_path(path) }
|
||||
#[tauri::command]
|
||||
pub fn expand_env_vars(path: &str) -> String { system::expand_env_vars(path) }
|
||||
#[tauri::command]
|
||||
pub fn broadcast_env_change() { system::broadcast_env_change() }
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "PathEditor",
|
||||
"version": "4.3.0",
|
||||
"version": "5.0.0",
|
||||
"identifier": "com.liuhangyu.patheditor",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "PathEditor v4.3",
|
||||
"title": "PathEditor v5.0",
|
||||
"width": 900,
|
||||
"height": 700,
|
||||
"minWidth": 800,
|
||||
@@ -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>
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
{
|
||||
"name": "patheditor",
|
||||
"private": true,
|
||||
"version": "4.3.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:e2e": "playwright test --config e2e/playwright.config.ts"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
@@ -25,17 +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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable-x86_64-pc-windows-gnu"
|
||||
@@ -1,5 +0,0 @@
|
||||
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
|
||||
#[tauri::command]
|
||||
pub fn read_text_file(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn profiles_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".patheditor")
|
||||
.join("profiles")
|
||||
}
|
||||
|
||||
fn profile_path(name: &str) -> PathBuf {
|
||||
profiles_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ProfilePathEntry {
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ProfileMeta {
|
||||
pub name: String,
|
||||
pub created: String,
|
||||
pub modified: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ProfileData {
|
||||
pub name: String,
|
||||
pub sys: Vec<ProfilePathEntry>,
|
||||
pub user: Vec<ProfilePathEntry>,
|
||||
pub created: String,
|
||||
pub modified: String,
|
||||
}
|
||||
|
||||
/// 列出所有配置文件的元数据
|
||||
#[tauri::command]
|
||||
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
|
||||
let dir = profiles_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut profiles: Vec<ProfileMeta> = Vec::new();
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
|
||||
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
|
||||
profiles.push(ProfileMeta {
|
||||
name: data.name,
|
||||
created: data.created,
|
||||
modified: data.modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
profiles.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// 保存当前 PATH 为配置文件
|
||||
#[tauri::command]
|
||||
pub fn save_profile(
|
||||
name: String,
|
||||
sys: Vec<ProfilePathEntry>,
|
||||
user: Vec<ProfilePathEntry>,
|
||||
) -> Result<(), String> {
|
||||
let dir = profiles_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
|
||||
|
||||
let path = profile_path(&name);
|
||||
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
|
||||
let data = ProfileData {
|
||||
name,
|
||||
sys,
|
||||
user,
|
||||
created: now.clone(),
|
||||
modified: now,
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
||||
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
|
||||
|
||||
log::info!("已保存配置: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 加载配置文件
|
||||
#[tauri::command]
|
||||
pub fn load_profile(name: String) -> Result<ProfileData, String> {
|
||||
let path = profile_path(&name);
|
||||
if !path.exists() {
|
||||
return Err(format!("配置文件不存在: {}", name));
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("无法读取配置文件: {}", e))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("JSON 解析失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除配置文件
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(name: String) -> Result<(), String> {
|
||||
let path = profile_path(&name);
|
||||
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
|
||||
log::info!("已删除配置: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重命名配置文件
|
||||
#[tauri::command]
|
||||
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> {
|
||||
let old_path = profile_path(&old_name);
|
||||
if !old_path.exists() {
|
||||
return Err(format!("配置文件不存在: {}", old_name));
|
||||
}
|
||||
|
||||
let mut data: ProfileData =
|
||||
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
|
||||
|
||||
data.name = new_name.clone();
|
||||
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
|
||||
let new_path = profile_path(&new_name);
|
||||
let json =
|
||||
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
||||
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
|
||||
|
||||
if old_path != new_path {
|
||||
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
|
||||
}
|
||||
|
||||
log::info!("已重命名配置: {} -> {}", old_name, new_name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct ConflictLocation {
|
||||
pub dir: String,
|
||||
pub priority: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct ConflictEntry {
|
||||
pub name: String,
|
||||
pub locations: Vec<ConflictLocation>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ToolGroup {
|
||||
pub dir: String,
|
||||
pub exists: bool,
|
||||
pub exes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 扫描 PATH 中的可执行文件冲突
|
||||
///
|
||||
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
|
||||
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
|
||||
#[tauri::command]
|
||||
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
|
||||
// exe_name (小写) → [(priority, dir)]
|
||||
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
|
||||
|
||||
for (priority, dir) in paths.iter().enumerate() {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
|
||||
for entry in entries.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let name = fname.to_string_lossy();
|
||||
if let Some(ext) = Path::new(name.as_ref()).extension() {
|
||||
let ext_lower = ext.to_ascii_lowercase();
|
||||
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
|
||||
let key = name.to_lowercase();
|
||||
map.entry(key).or_default().push((priority, dir.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut results: Vec<ConflictEntry> = map
|
||||
.into_iter()
|
||||
.filter(|(_, locs)| locs.len() >= 2)
|
||||
.map(|(name, locs)| ConflictEntry {
|
||||
name,
|
||||
locations: locs
|
||||
.into_iter()
|
||||
.map(|(priority, dir)| ConflictLocation { dir, priority })
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 扫描 PATH 中各目录提供的可执行文件
|
||||
///
|
||||
/// query 非空时只返回文件名包含关键词的结果
|
||||
#[tauri::command]
|
||||
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut groups: Vec<ToolGroup> = Vec::new();
|
||||
|
||||
for dir in &paths {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
groups.push(ToolGroup {
|
||||
dir: dir.clone(),
|
||||
exists: false,
|
||||
exes: vec![],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
|
||||
let mut exes: Vec<String> = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let name = fname.to_string_lossy();
|
||||
if let Some(ext) = Path::new(name.as_ref()).extension() {
|
||||
let ext_lower = ext.to_ascii_lowercase();
|
||||
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
|
||||
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
|
||||
exes.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exes.sort();
|
||||
groups.push(ToolGroup {
|
||||
dir: dir.clone(),
|
||||
exists: true,
|
||||
exes,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -35,8 +35,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
const [toolGroups, setToolGroups] = useState<ToolGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const prevOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || prevOpen.current) return;
|
||||
prevOpen.current = open;
|
||||
setLoading(true);
|
||||
const paths = getEnabledPaths();
|
||||
Promise.all([
|
||||
@@ -64,7 +66,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
<h2 className="text-base font-semibold">{t('analyze.title')}</h2>
|
||||
<div className="flex gap-1">
|
||||
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
|
||||
@@ -86,7 +91,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm" style={{ color: 'var(--app-fg)', opacity: 0.6 }}>
|
||||
<div
|
||||
className="flex items-center justify-center py-12 text-sm"
|
||||
style={{ color: 'var(--app-fg)', opacity: 0.6 }}
|
||||
>
|
||||
{t('analyze.scanning')}
|
||||
</div>
|
||||
) : tab === 'conflicts' ? (
|
||||
@@ -212,5 +220,7 @@ function EmptyHint({ text }: { text: string }) {
|
||||
|
||||
function getEnabledPaths(): string[] {
|
||||
const { sysPaths, userPaths } = useAppStore.getState();
|
||||
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path);
|
||||
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map(
|
||||
(e) => e.path,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface HelpDialogProps { open: boolean; onClose: () => void; }
|
||||
interface HelpDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -9,9 +12,15 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">{t('help.content')}</pre>
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">
|
||||
{t('help.content')}
|
||||
</pre>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={onClose}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded text-white"
|
||||
style={{ backgroundColor: '#2563eb' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('dialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,13 @@ interface ImportDialogProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel }: ImportDialogProps) {
|
||||
export function ImportDialog({
|
||||
open,
|
||||
systemCount,
|
||||
userCount,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: ImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -21,10 +27,40 @@ export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel
|
||||
{userCount > 0 && `用户变量: ${userCount} 条`}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{systemCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('system')}>{t('dialog.importSystem')}</button>}
|
||||
{userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('user')}>{t('dialog.importUser')}</button>}
|
||||
{systemCount > 0 && userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('both')}>{t('dialog.importBoth')}</button>}
|
||||
<button className="px-4 py-2 text-sm rounded border mt-2" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>{t('dialog.cancel')}</button>
|
||||
{systemCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('system')}
|
||||
>
|
||||
{t('dialog.importSystem')}
|
||||
</button>
|
||||
)}
|
||||
{userCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('user')}
|
||||
>
|
||||
{t('dialog.importUser')}
|
||||
</button>
|
||||
)}
|
||||
{systemCount > 0 && userCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('both')}
|
||||
>
|
||||
{t('dialog.importBoth')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border mt-2"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
@@ -10,30 +10,54 @@ interface PathEditDialogProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel }: PathEditDialogProps) {
|
||||
export function PathEditDialog({
|
||||
open,
|
||||
title,
|
||||
initialValue,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PathEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const prevOpen = useRef(open);
|
||||
|
||||
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
||||
useEffect(() => {
|
||||
if (open && !prevOpen.current) setValue(initialValue);
|
||||
prevOpen.current = open;
|
||||
}, [open, initialValue]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel}>
|
||||
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
||||
<input
|
||||
type="text" autoFocus value={value}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onConfirm(value); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onConfirm(value);
|
||||
}}
|
||||
className="w-full min-w-[400px] px-3 py-2 rounded border text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button className="px-4 py-1.5 text-sm rounded border" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded border"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={() => onConfirm(value)}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded text-white"
|
||||
style={{ backgroundColor: '#2563eb' }}
|
||||
onClick={() => onConfirm(value)}
|
||||
>
|
||||
{t('dialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -39,8 +39,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
setProfiles(list);
|
||||
}, []);
|
||||
|
||||
const prevOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open) refreshProfiles();
|
||||
if (open && !prevOpen.current) refreshProfiles();
|
||||
prevOpen.current = open;
|
||||
}, [open, refreshProfiles]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -63,13 +65,13 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
if (!selected || !selectedData) return;
|
||||
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
|
||||
useAppStore.getState().replaceBothPaths(
|
||||
selectedData.sys.map(e => e.path),
|
||||
selectedData.user.map(e => e.path),
|
||||
selectedData.sys.map((e) => e.path),
|
||||
selectedData.user.map((e) => e.path),
|
||||
);
|
||||
// 同步 disabled 状态
|
||||
await invoke('save_disabled_state', {
|
||||
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
|
||||
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
|
||||
system: selectedData.sys.filter((e) => !e.enabled).map((e) => e.path),
|
||||
user: selectedData.user.filter((e) => !e.enabled).map((e) => e.path),
|
||||
});
|
||||
await useAppStore.getState().savePaths();
|
||||
onClose();
|
||||
@@ -78,7 +80,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!window.confirm(`删除配置文件 "${name}"?`)) return;
|
||||
await invoke('delete_profile', { name });
|
||||
if (selected === name) { setSelected(null); setSelectedData(null); }
|
||||
if (selected === name) {
|
||||
setSelected(null);
|
||||
setSelectedData(null);
|
||||
}
|
||||
refreshProfiles();
|
||||
};
|
||||
|
||||
@@ -93,16 +98,23 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
<h2 className="text-base font-semibold">{t('profile.title')}</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
className="px-2 py-1 text-sm rounded border outline-none w-44"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="px-3 py-1 text-sm rounded text-white"
|
||||
@@ -125,11 +137,16 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 左侧:列表 */}
|
||||
<div className="w-48 border-r overflow-auto p-2" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="w-48 border-r overflow-auto p-2"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
{profiles.length === 0 ? (
|
||||
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>{t('profile.noProfiles')}</div>
|
||||
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>
|
||||
{t('profile.noProfiles')}
|
||||
</div>
|
||||
) : (
|
||||
profiles.map(p => (
|
||||
profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
onClick={() => handleLoad(p.name)}
|
||||
@@ -155,7 +172,9 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-semibold text-sm">{selectedData.name}</span>
|
||||
<span className="text-xs" style={{ opacity: 0.5 }}>{selectedData.modified}</span>
|
||||
<span className="text-xs" style={{ opacity: 0.5 }}>
|
||||
{selectedData.modified}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
@@ -169,7 +188,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<button
|
||||
className="px-3 py-1 text-xs rounded"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
|
||||
onClick={() => { setRenameOpen(true); setRenameValue(selectedData.name); }}
|
||||
onClick={() => {
|
||||
setRenameOpen(true);
|
||||
setRenameValue(selectedData.name);
|
||||
}}
|
||||
>
|
||||
{t('profile.rename')}
|
||||
</button>
|
||||
@@ -187,18 +209,32 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border outline-none"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<button className="px-2 py-1 text-xs rounded text-white" style={{ backgroundColor: '#3b82f6' }} onClick={handleRename}>
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded text-white"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
onClick={handleRename}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PathSection title={`系统 PATH (${selectedData.sys.length})`} paths={selectedData.sys} />
|
||||
<PathSection title={`用户 PATH (${selectedData.user.length})`} paths={selectedData.user} />
|
||||
<PathSection
|
||||
title={`系统 PATH (${selectedData.sys.length})`}
|
||||
paths={selectedData.sys}
|
||||
/>
|
||||
<PathSection
|
||||
title={`用户 PATH (${selectedData.user.length})`}
|
||||
paths={selectedData.user}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,9 +247,13 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>{title}</div>
|
||||
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>
|
||||
{title}
|
||||
</div>
|
||||
{paths.length === 0 ? (
|
||||
<div className="text-xs" style={{ opacity: 0.4 }}>(空)</div>
|
||||
<div className="text-xs" style={{ opacity: 0.4 }}>
|
||||
(空)
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-48 overflow-auto">
|
||||
{paths.map((e, i) => (
|
||||
|
||||
@@ -28,19 +28,32 @@ export function AppShell() {
|
||||
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
||||
|
||||
const [editDialog, setEditDialog] = useState<DialogState['editDialog']>({
|
||||
open: false, index: -1, value: '', target: TargetType.SYSTEM,
|
||||
open: false,
|
||||
index: -1,
|
||||
value: '',
|
||||
target: TargetType.SYSTEM,
|
||||
});
|
||||
const [newDialog, setNewDialog] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
||||
open: false, system: [], user: [],
|
||||
open: false,
|
||||
system: [],
|
||||
user: [],
|
||||
});
|
||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
|
||||
const actions = useAppActions(activeTab, {
|
||||
editDialog, newDialog, helpOpen, importDialog,
|
||||
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
|
||||
editDialog,
|
||||
newDialog,
|
||||
helpOpen,
|
||||
importDialog,
|
||||
setEditDialog,
|
||||
setNewDialog,
|
||||
setHelpOpen,
|
||||
setImportDialog,
|
||||
setAnalyzeOpen,
|
||||
setProfilesOpen,
|
||||
});
|
||||
|
||||
const tabConfig: { id: TabId; label: string }[] = [
|
||||
@@ -50,14 +63,20 @@ export function AppShell() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||
<div
|
||||
className="flex flex-col h-screen"
|
||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<TitleBar />
|
||||
|
||||
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
|
||||
{tabConfig.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id); setSelectedIndices([]); }}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setSelectedIndices([]);
|
||||
}}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition-colors ${activeTab === tab.id ? 'tab-active' : 'opacity-60'}`}
|
||||
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
|
||||
>
|
||||
@@ -96,7 +115,10 @@ export function AppShell() {
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (activeTab === 'merged') return;
|
||||
@@ -104,20 +126,47 @@ export function AppShell() {
|
||||
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
||||
if (entry?.isDirectory) {
|
||||
const file = e.dataTransfer.files[i] as TauriFile;
|
||||
if (file.path) useAppStore.getState().addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
if (file.path)
|
||||
useAppStore
|
||||
.getState()
|
||||
.addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeTab === 'merged' ? <MergePreview /> : <PathTable tabId={activeTab as 'system' | 'user'} />}
|
||||
{activeTab === 'merged' ? (
|
||||
<MergePreview />
|
||||
) : (
|
||||
<PathTable tabId={activeTab as 'system' | 'user'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
<PathEditDialog open={newDialog} title={t('dialog.newPath')} initialValue="" onConfirm={actions.handleNewConfirm} onCancel={() => setNewDialog(false)} />
|
||||
<PathEditDialog open={editDialog.open} title={t('dialog.editPath')} initialValue={editDialog.value} onConfirm={actions.handleEditConfirm} onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} />
|
||||
<PathEditDialog
|
||||
open={newDialog}
|
||||
title={t('dialog.newPath')}
|
||||
initialValue=""
|
||||
onConfirm={actions.handleNewConfirm}
|
||||
onCancel={() => setNewDialog(false)}
|
||||
/>
|
||||
<PathEditDialog
|
||||
open={editDialog.open}
|
||||
title={t('dialog.editPath')}
|
||||
initialValue={editDialog.value}
|
||||
onConfirm={actions.handleEditConfirm}
|
||||
onCancel={() =>
|
||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })
|
||||
}
|
||||
/>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
<ImportDialog open={importDialog.open} systemCount={importDialog.system.length} userCount={importDialog.user.length} onSelect={actions.handleImportSelect} onCancel={() => setImportDialog({ open: false, system: [], user: [] })} />
|
||||
<ImportDialog
|
||||
open={importDialog.open}
|
||||
systemCount={importDialog.system.length}
|
||||
userCount={importDialog.user.length}
|
||||
onSelect={actions.handleImportSelect}
|
||||
onCancel={() => setImportDialog({ open: false, system: [], user: [] })}
|
||||
/>
|
||||
<AnalyzeDialog open={analyzeOpen} onClose={() => setAnalyzeOpen(false)} />
|
||||
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props { children: ReactNode; }
|
||||
interface State { hasError: boolean; error: string; }
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: '' };
|
||||
@@ -18,7 +23,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||
<div
|
||||
className="flex items-center justify-center h-screen"
|
||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-bold">应用出错</h2>
|
||||
<p className="text-sm opacity-70">{this.state.error}</p>
|
||||
|
||||