Compare commits
20 Commits
v4.2.0
...
7aa5dcd832
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aa5dcd832 | |||
| 9b5b57a3ac | |||
| 1320aa57a8 | |||
| a553a16a64 | |||
| c181fe15d4 | |||
| 36e1c89b2e | |||
| 812f39b159 | |||
| cd896d389b | |||
| 5a864c41b2 | |||
| 986fe7f0d9 | |||
| 9c74c61d64 | |||
| 26f6953919 | |||
| 5ed15535e7 | |||
| 230fb5d741 | |||
| d7d11480b8 | |||
| 7869886670 | |||
| 49ef9c0cff | |||
| 344011a02c | |||
| 3aed03f599 | |||
| d7bc752b84 |
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Bug 报告
|
||||||
|
about: 提交问题报告帮助改进 PathEditor
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
<!-- 清晰描述 bug 是什么 -->
|
||||||
|
|
||||||
|
## 复现步骤
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## 期望行为
|
||||||
|
|
||||||
|
## 截图(如有)
|
||||||
|
|
||||||
|
## 系统信息
|
||||||
|
|
||||||
|
- Windows 版本:
|
||||||
|
- 是否管理员:
|
||||||
|
- PathEditor 版本:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: 功能建议
|
||||||
|
about: 建议新功能或改进
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
<!-- 你会在什么场景下需要这个功能? -->
|
||||||
|
|
||||||
|
## 建议方案
|
||||||
|
|
||||||
|
<!-- 你期望的功能是什么样的? -->
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: src-tauri
|
working-directory: gui
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ jobs:
|
|||||||
- name: Tauri Build
|
- name: Tauri Build
|
||||||
run: npx tauri build
|
run: npx tauri build
|
||||||
|
|
||||||
- name: 创建 Release 并上传安装包
|
- name: 上传安装包到 Release
|
||||||
run: |
|
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 {
|
||||||
gh release create $env:GITHUB_REF_NAME "$installer" --title "$env:GITHUB_REF_NAME" --generate-notes
|
gh release create $env:GITHUB_REF_NAME "$installer" --title "$env:GITHUB_REF_NAME" --generate-notes
|
||||||
|
}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -1,38 +1,77 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v4.0.0 (2026-05-26)
|
## [4.2.0] — 2026-05-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- 路径启用/禁用功能:复选框控制 PATH 中每条路径是否生效
|
||||||
|
- PathEntry 数据类型:替代原有 `string[]`,支持 `enabled` 状态
|
||||||
|
- `disabled.json` 持久化禁用状态的独立存储
|
||||||
|
- E2E 测试框架:Playwright + 4 条核心流程测试
|
||||||
|
- CI/CD 流水线:TypeScript + Rust 自动检查,Release 自动构建
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- undo/redo after toggle 未持久化 disabled 状态
|
||||||
|
- expand_env_vars 两次 API 调用间缓冲区截断风险
|
||||||
|
- E2E mock load_disabled_state 返回格式与 Rust 后端不匹配
|
||||||
|
- 双 hive 保存失败时错误信息只显示一个
|
||||||
|
- 导入 both 产生两条 undo 记录,需两次 Ctrl+Z
|
||||||
|
- 备份失败警告被"保存成功"覆盖
|
||||||
|
- 非连续多行删除后 undo 恢复到错误位置
|
||||||
|
- backup_registry 未 await 导致竞态保存新值
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
- 导入改用原生文件对话框(`@tauri-apps/plugin-dialog`)
|
||||||
|
- PathTable 环境变量展开限流 20 并发
|
||||||
|
- CI 切换到 MSVC 工具链
|
||||||
|
- 版本号统一为 4.2.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.1.0] — 2026-05-26
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- app-store 单元测试:25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths
|
||||||
|
- 72 个前端单元测试 + 10 个 Rust 单元测试
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- NSIS 安装包缺少 WebView2Loader.dll
|
||||||
|
- AppShell overflow-hidden 导致窗口无法上下滚动
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
- 清理 LOW 问题:样式去重、死代码删除、命名修正
|
||||||
|
- 抽取 Modal 共享组件、统一按钮样式
|
||||||
|
- 支持 JSON/CSV/TXT 三种导入导出格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [4.0.0] — 2026-05-25
|
||||||
|
|
||||||
### 重大变更
|
### 重大变更
|
||||||
|
|
||||||
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
|
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
- 现代 Web UI(React 19 + Tailwind CSS 4 + Zustand)
|
||||||
- 现代 Web UI(React + Tailwind CSS 4 + Zustand)
|
|
||||||
- 深色/浅色模式切换
|
- 深色/浅色模式切换
|
||||||
- 中英文界面即时切换
|
- 中英文界面即时切换
|
||||||
- 路径有效性颜色编码(红色无效、橙色重复)
|
- 路径有效性颜色编码(红色无效、橙色重复)
|
||||||
- 环境变量展开悬停提示
|
- 环境变量展开悬停提示
|
||||||
- 文件夹拖拽添加路径
|
- 文件夹拖拽添加路径
|
||||||
- 保存前 PATH 长度检查
|
- 保存前 PATH 长度检查
|
||||||
- 66 个前端单元测试 + 10 个 Rust 单元测试
|
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|
||||||
- 安装包体积从 ~3MB 降至 ~8MB(含 WebView2 运行时)
|
|
||||||
- 完整撤销/重做支持(8 种操作类型,50 步历史)
|
- 完整撤销/重做支持(8 种操作类型,50 步历史)
|
||||||
- JSON/CSV/TXT 三种格式导入导出
|
- JSON/CSV/TXT 三种格式导入导出
|
||||||
- 合并预览查看系统+用户路径
|
- 合并预览查看系统+用户路径
|
||||||
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
|
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
|
||||||
|
- NSIS 安装包,约 8MB
|
||||||
|
|
||||||
### 移除
|
### 移除
|
||||||
|
|
||||||
- 旧 C + IUP + Lua + gettext 代码库
|
- 旧 C + IUP + Lua + gettext 代码库
|
||||||
- Lua 配置引擎 → JSON 配置文件
|
- Lua 配置引擎 → JSON 配置文件
|
||||||
- gettext 国际化 → i18next
|
- gettext 国际化 → i18next
|
||||||
|
|
||||||
### 已知限制
|
---
|
||||||
|
|
||||||
- 需要 Windows 10+ 系统预装的 WebView2 运行时
|
## [3.x] 及更早
|
||||||
- 内存占用约 50MB(旧版约 15MB)
|
|
||||||
- 文件系统路径验证在清理功能中为同步检查(不含实际目录存在性验证)
|
C + IUP GUI 版本,已停止维护。历史发布记录见 [GitHub Releases](https://github.com/LHY0125/PathEditor/releases)。
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
感谢你对 PathEditor 的关注!
|
||||||
|
|
||||||
|
## 提交 Issue
|
||||||
|
|
||||||
|
- 使用清晰的标题描述问题
|
||||||
|
- 提供复现步骤
|
||||||
|
- 附上系统信息(Windows 版本、是否管理员)
|
||||||
|
- 如果是功能建议,说明使用场景
|
||||||
|
|
||||||
|
## 提交 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
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [README.md](./README.md#开发)。
|
||||||
@@ -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,13 +4,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-4.0.0-blue" alt="version">
|
<img src="https://img.shields.io/badge/version-5.0.0-blue" alt="version">
|
||||||
<img src="https://img.shields.io/badge/tauri-2.x-ffa03a" alt="tauri">
|
<img src="https://img.shields.io/badge/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/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/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/typescript-strict-blue" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
||||||
<img src="https://img.shields.io/badge/tests-55%20passed-brightgreen" alt="tests">
|
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
|
||||||
|
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,11 +20,113 @@
|
|||||||
|
|
||||||
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
|
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
|
||||||
|
|
||||||
v4.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
|
v5.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
|
||||||
|
|
||||||
## 截图
|
## 架构
|
||||||
|
|
||||||
_[待补充]_
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph 前端["React 前端"]
|
||||||
|
UI[UI 组件层<br/>AppShell / PathTable / Dialogs]
|
||||||
|
Store[状态管理<br/>Zustand Store]
|
||||||
|
Core[纯逻辑层<br/>undo-redo / path-manager / validation]
|
||||||
|
UI --> Store
|
||||||
|
UI --> Core
|
||||||
|
Store --> Core
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph IPC["Tauri IPC 桥接"]
|
||||||
|
invoke[invoke / plugin-dialog]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 后端["Rust 后端"]
|
||||||
|
Registry[注册表读写<br/>HKLM / HKCU]
|
||||||
|
System[系统操作<br/>权限检测 / 路径验证 / 环境变量展开]
|
||||||
|
Files[文件操作<br/>备份 / 配置 / 导入读取]
|
||||||
|
Scanner[分析引擎<br/>冲突检测 / 工具清单]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Windows["Windows 系统"]
|
||||||
|
Reg[(注册表<br/>SYSTEM / USER PATH)]
|
||||||
|
FS[(文件系统<br/>目录验证 / exe 扫描)]
|
||||||
|
end
|
||||||
|
|
||||||
|
UI --> invoke
|
||||||
|
invoke --> Registry
|
||||||
|
invoke --> System
|
||||||
|
invoke --> Files
|
||||||
|
invoke --> Scanner
|
||||||
|
Registry --> Reg
|
||||||
|
System --> FS
|
||||||
|
Scanner --> FS
|
||||||
|
Files --> FS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件树
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
App["App.tsx<br/>ErrorBoundary"]
|
||||||
|
Shell["AppShell<br/>布局编排 + 弹窗管理"]
|
||||||
|
TitleBar["TitleBar<br/>拖拽区域"]
|
||||||
|
ToolBar["ToolBar<br/>搜索 / 操作 / 分析 / 配置"]
|
||||||
|
PathTable["PathTable<br/>路径列表 + 验证 + 复选框"]
|
||||||
|
MergePreview["MergePreview<br/>系统+用户合并视图"]
|
||||||
|
StatusBar["StatusBar<br/>状态 / 权限 / 重试"]
|
||||||
|
Dialogs["弹窗层<br/>PathEdit / Import / Help / Analyze / Profile"]
|
||||||
|
|
||||||
|
App --> Shell
|
||||||
|
Shell --> TitleBar
|
||||||
|
Shell --> ToolBar
|
||||||
|
Shell --> PathTable
|
||||||
|
Shell --> MergePreview
|
||||||
|
Shell --> StatusBar
|
||||||
|
Shell --> Dialogs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor U as 用户
|
||||||
|
participant UI as React UI
|
||||||
|
participant Z as Zustand Store
|
||||||
|
participant IPC as Tauri IPC
|
||||||
|
participant R as Rust 后端
|
||||||
|
participant Win as Windows
|
||||||
|
|
||||||
|
U->>UI: 点击「保存」
|
||||||
|
UI->>Z: savePaths()
|
||||||
|
Z->>IPC: invoke('backup_registry')
|
||||||
|
IPC->>R: backup_registry()
|
||||||
|
R->>Win: 读取注册表 → 写入备份文件
|
||||||
|
Z->>IPC: Promise.allSettled([save_system, save_user])
|
||||||
|
IPC->>R: save_system_paths() / save_user_paths()
|
||||||
|
R->>Win: RegSetValueEx()
|
||||||
|
Z->>IPC: invoke('broadcast_env_change')
|
||||||
|
IPC->>R: SendMessageTimeout(WM_SETTINGCHANGE)
|
||||||
|
R->>Win: 通知所有进程
|
||||||
|
Z->>UI: isModified → false, statusMessage → '保存成功'
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI 命令行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装
|
||||||
|
cargo install --path cli
|
||||||
|
|
||||||
|
# 查看 PATH
|
||||||
|
patheditor list --system --json
|
||||||
|
|
||||||
|
# 冲突检测
|
||||||
|
patheditor conflicts
|
||||||
|
|
||||||
|
# 配置切换
|
||||||
|
patheditor profile save "Python开发"
|
||||||
|
patheditor profile apply "Python开发"
|
||||||
|
```
|
||||||
|
|
||||||
|
完整 17 条命令:`patheditor --help`
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
@@ -41,7 +144,7 @@ _[待补充]_
|
|||||||
- 环境变量路径(含 `%VAR%`)悬浮展开预览
|
- 环境变量路径(含 `%VAR%`)悬浮展开预览
|
||||||
|
|
||||||
### 撤销/重做
|
### 撤销/重做
|
||||||
- 支持 8 种操作类型,最多 50 步历史
|
- 支持 9 种操作类型,最多 50 步历史
|
||||||
- 新增、删除、编辑、移动、清理、清空、导入均可撤销
|
- 新增、删除、编辑、移动、清理、清空、导入均可撤销
|
||||||
|
|
||||||
### 导入/导出
|
### 导入/导出
|
||||||
@@ -63,7 +166,7 @@ _[待补充]_
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.0.0_x64-setup.exe` 安装。
|
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_5.0.0_x64-setup.exe` 安装。
|
||||||
|
|
||||||
或从源码构建:
|
或从源码构建:
|
||||||
|
|
||||||
@@ -80,7 +183,7 @@ npx tauri build
|
|||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开发模式(热更新)
|
# 开发模式 GUI(热更新)
|
||||||
npx tauri dev
|
npx tauri dev
|
||||||
|
|
||||||
# 仅前端
|
# 仅前端
|
||||||
@@ -89,11 +192,14 @@ npm run dev
|
|||||||
# 前端测试
|
# 前端测试
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
# Rust 后端检查
|
# Rust workspace 检查
|
||||||
cd src-tauri && cargo check
|
cargo check
|
||||||
|
|
||||||
# Rust 后端测试
|
# CLI 构建
|
||||||
cd src-tauri && cargo test
|
cargo build --release -p patheditor-cli
|
||||||
|
|
||||||
|
# 完整构建
|
||||||
|
npx tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 技术栈
|
### 技术栈
|
||||||
@@ -105,34 +211,33 @@ cd src-tauri && cargo test
|
|||||||
| 状态管理 | Zustand |
|
| 状态管理 | Zustand |
|
||||||
| 国际化 | i18next |
|
| 国际化 | i18next |
|
||||||
| 桌面框架 | Tauri 2.x |
|
| 桌面框架 | Tauri 2.x |
|
||||||
| 后端 | Rust (winreg + windows-rs FFI) |
|
| 核心库 | Rust workspace (core + gui + cli) |
|
||||||
| 前端测试 | Vitest (45 个测试) |
|
| 前端测试 | Vitest (72 个测试) |
|
||||||
| Rust 测试 | cargo test (10 个测试) |
|
| Rust 测试 | cargo test (10 个测试) |
|
||||||
| 构建 | Vite |
|
| 构建 | Vite + Cargo |
|
||||||
| 打包 | NSIS |
|
| 打包 | 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 # 17 条命令
|
||||||
src/ # React 前端
|
src/ # React 前端
|
||||||
├── core/ # 纯逻辑 — 零框架依赖、零平台依赖
|
├── core/ # 纯逻辑 — 零框架依赖
|
||||||
├── store/ # Zustand 状态管理
|
├── store/ # Zustand 状态管理
|
||||||
├── components/
|
├── components/ # UI 组件
|
||||||
│ ├── layout/ # AppShell、TitleBar、StatusBar、ErrorBoundary
|
|
||||||
│ ├── path-list/ # PathTable、MergePreview
|
|
||||||
│ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons、SearchInput
|
|
||||||
│ ├── dialogs/ # PathEditDialog、HelpDialog、ImportDialog
|
|
||||||
│ └── ui/ # Modal、buttons(共享组件)
|
|
||||||
├── hooks/ # useAppActions、useKeyboard
|
├── hooks/ # useAppActions、useKeyboard
|
||||||
├── i18n/ # zh-CN / en
|
├── i18n/ # zh-CN / en
|
||||||
└── config/ # default.json
|
└── config/ # default.json
|
||||||
|
|
||||||
src-tauri/ # Rust 后端
|
|
||||||
└── src/commands/
|
|
||||||
├── registry.rs # 注册表读写
|
|
||||||
├── system.rs # 权限检测、路径验证、环境变量展开
|
|
||||||
└── backup.rs # 注册表备份
|
|
||||||
|
|
||||||
tests/unit/ # 前端单元测试
|
tests/unit/ # 前端单元测试
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
path-editor-core = { path = "../core" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use path_editor_core as core;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "patheditor", version = "5.0.0")]
|
||||||
|
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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || (!system && !user) {
|
||||||
|
sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
|
||||||
|
}
|
||||||
|
if user || (!system && !user) {
|
||||||
|
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 || false, |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 || false, |mut list| {
|
||||||
|
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
|
||||||
|
let end = if up {
|
||||||
|
if steps > index { 0 } else { index - 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) {
|
||||||
|
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))
|
||||||
|
};
|
||||||
|
let (kept, removed) = core::registry::clean_paths(list.clone());
|
||||||
|
|
||||||
|
if json_out {
|
||||||
|
println!("{}", json!({ "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }).to_string());
|
||||||
|
} else if dry_run {
|
||||||
|
println!("═══ 将被移除({} 条)═══", removed.len());
|
||||||
|
for r in &removed { println!(" ✗ {}", r); }
|
||||||
|
println!("═══ 将保留({} 条)═══", kept.len());
|
||||||
|
for k in &kept { println!(" ✓ {}", k); }
|
||||||
|
} else {
|
||||||
|
let kept_count = kept.len();
|
||||||
|
verify_and_save(target, &list, kept);
|
||||||
|
println!("清理完成:移除 {} 条,保留 {} 条", removed.len(), kept_count);
|
||||||
|
core::system::broadcast_env_change();
|
||||||
|
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);
|
||||||
|
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 sys: Vec<String> = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
|
||||||
|
let usr: Vec<String> = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
|
||||||
|
core::registry::save_system_paths(sys).unwrap_or_else(|e| exit_err(&e));
|
||||||
|
core::registry::save_user_paths(usr).unwrap_or_else(|e| exit_err(&e));
|
||||||
|
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 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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use winreg::enums::*;
|
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 {
|
fn backup_base_dir() -> PathBuf {
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
@@ -12,14 +12,13 @@ fn backup_base_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 APPDATA 路径下的备份目录
|
/// 获取 APPDATA 路径下的备份目录
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_appdata_dir() -> String {
|
pub fn get_appdata_dir() -> String {
|
||||||
backup_base_dir().to_string_lossy().to_string()
|
backup_base_dir().to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
||||||
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
|
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
|
||||||
#[tauri::command]
|
|
||||||
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
||||||
let backup_dir = match custom_dir {
|
let backup_dir = match custom_dir {
|
||||||
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
|
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
|
||||||
@@ -19,7 +19,6 @@ struct DisabledState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
|
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
|
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
|
||||||
let state = DisabledState { system, user };
|
let state = DisabledState { system, user };
|
||||||
let path = disabled_file_path();
|
let path = disabled_file_path();
|
||||||
@@ -40,7 +39,6 @@ pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
|
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
|
||||||
#[tauri::command]
|
|
||||||
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
||||||
let path = disabled_file_path();
|
let path = disabled_file_path();
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
|
||||||
|
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();
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() { 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) -> String {
|
||||||
|
match format {
|
||||||
|
"json" => {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"version": "5.0.0",
|
||||||
|
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"system": sys,
|
||||||
|
"user": usr,
|
||||||
|
});
|
||||||
|
serde_json::to_string_pretty(&data).unwrap_or_default()
|
||||||
|
}
|
||||||
|
"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));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,152 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有配置文件的元数据
|
||||||
|
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 为配置文件
|
||||||
|
pub fn save_profile(
|
||||||
|
name: &str,
|
||||||
|
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 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> {
|
||||||
|
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> {
|
||||||
|
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> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use winreg::RegKey;
|
|||||||
|
|
||||||
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
||||||
pub(crate) const USER_REG_PATH: &str = "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> {
|
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
|
||||||
let key = RegKey::predef(root);
|
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))
|
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);
|
let value = join_path(paths);
|
||||||
|
|
||||||
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
|
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
|
||||||
@@ -43,36 +43,36 @@ pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn load_system_paths() -> Result<Vec<String>, String> {
|
pub fn load_system_paths() -> Result<Vec<String>, String> {
|
||||||
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
|
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn load_user_paths() -> Result<Vec<String>, String> {
|
pub fn load_user_paths() -> Result<Vec<String>, String> {
|
||||||
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
|
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
||||||
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
|
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
||||||
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
|
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将分号分隔的 PATH 字符串拆分为数组。
|
/// 将分号分隔的 PATH 字符串拆分为数组。
|
||||||
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_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(';')
|
raw.split(';')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn join_path(paths: &[String]) -> String {
|
fn join_path(paths: &[String]) -> String {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.trim())
|
.map(|p| p.trim())
|
||||||
@@ -81,6 +81,29 @@ pub(crate) fn join_path(paths: &[String]) -> String {
|
|||||||
.join(";")
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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 文件,
|
||||||
|
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
|
||||||
|
|
||||||
|
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 非空时只返回文件名包含关键词的结果
|
||||||
|
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let mut groups: Vec<ToolGroup> = Vec::new();
|
||||||
|
|
||||||
|
for dir in &paths {
|
||||||
|
let p = Path::new(dir);
|
||||||
|
if !p.is_dir() {
|
||||||
|
groups.push(ToolGroup {
|
||||||
|
dir: dir.clone(),
|
||||||
|
exists: false,
|
||||||
|
exes: vec![],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
|
||||||
|
let mut exes: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let fname = entry.file_name();
|
||||||
|
let name = fname.to_string_lossy();
|
||||||
|
if let Some(ext) = Path::new(name.as_ref()).extension() {
|
||||||
|
let ext_lower = ext.to_ascii_lowercase();
|
||||||
|
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
|
||||||
|
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
|
||||||
|
exes.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exes.sort();
|
||||||
|
groups.push(ToolGroup {
|
||||||
|
dir: dir.clone(),
|
||||||
|
exists: true,
|
||||||
|
exes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(groups)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ use winreg::enums::*;
|
|||||||
use winreg::RegKey;
|
use winreg::RegKey;
|
||||||
|
|
||||||
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
|
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
|
||||||
#[tauri::command]
|
|
||||||
pub fn check_admin() -> bool {
|
pub fn check_admin() -> bool {
|
||||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
hklm.open_subkey_with_flags(
|
hklm.open_subkey_with_flags(
|
||||||
@@ -14,7 +13,7 @@ pub fn check_admin() -> bool {
|
|||||||
|
|
||||||
/// 验证路径是否存在于文件系统中(且是目录)
|
/// 验证路径是否存在于文件系统中(且是目录)
|
||||||
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
|
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
|
||||||
#[tauri::command]
|
|
||||||
pub fn validate_path(path: &str) -> bool {
|
pub fn validate_path(path: &str) -> bool {
|
||||||
if path.contains('%') {
|
if path.contains('%') {
|
||||||
return true;
|
return true;
|
||||||
@@ -23,7 +22,6 @@ pub fn validate_path(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin)
|
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin)
|
||||||
#[tauri::command]
|
|
||||||
pub fn expand_env_vars(path: &str) -> String {
|
pub fn expand_env_vars(path: &str) -> String {
|
||||||
if !path.contains('%') {
|
if !path.contains('%') {
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
@@ -53,8 +51,8 @@ pub fn expand_env_vars(path: &str) -> String {
|
|||||||
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
|
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
|
||||||
};
|
};
|
||||||
|
|
||||||
if result == 0 {
|
if result == 0 || result > required {
|
||||||
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
|
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +62,6 @@ pub fn expand_env_vars(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 广播环境变量更改通知(WM_SETTINGCHANGE)
|
/// 广播环境变量更改通知(WM_SETTINGCHANGE)
|
||||||
#[tauri::command]
|
|
||||||
pub fn broadcast_env_change() {
|
pub fn broadcast_env_change() {
|
||||||
const HWND_BROADCAST: isize = 0xFFFF;
|
const HWND_BROADCAST: isize = 0xFFFF;
|
||||||
const WM_SETTINGCHANGE: u32 = 0x001A;
|
const WM_SETTINGCHANGE: u32 = 0x001A;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# PathEditor v4.2 代码审查 — 隐含 Bug 分析报告
|
||||||
|
|
||||||
|
> 审查日期:2026-05-28 | 审查范围:全部前端核心模块 + Rust 后端 + E2E 测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG 1 [HIGH] — undo/redo TOGGLE 后不持久化 disabled 状态
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
`src/store/app-store.ts` — `togglePath()` (L213-237) vs `undo()` (L239-248) / `redo()` (L251-260)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
1. 用户勾选复选框禁用一个路径 → `togglePath` 立即调用 `invoke('save_disabled_state', ...)` 写入 `disabled.json`
|
||||||
|
2. 用户按 Ctrl+Z 撤销 → `undo()` 恢复内存中的 `enabled` 状态,但**不调用** `save_disabled_state`
|
||||||
|
3. `disabled.json` 中该路径仍标记为 disabled
|
||||||
|
4. 下次启动 `loadPaths` 时,从 `disabled.json` 读到的是旧数据,路径又被标记为 disabled
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`undo()` 和 `redo()` 中处理 TOGGLE 时,只更新了 Zustand store 中的 `sysPaths`/`userPaths`,没有像 `togglePath` 那样同步持久化 disabled 状态。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// togglePath — 有持久化
|
||||||
|
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }).catch(() => {});
|
||||||
|
|
||||||
|
// undo/redo — 没有持久化!
|
||||||
|
set({
|
||||||
|
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||||
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 影响
|
||||||
|
|
||||||
|
- 撤销 toggle 后刷新/重启应用,disabled 状态恢复为撤销前的值
|
||||||
|
- 数据和 UI 展示不一致
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
在 `undo()` 和 `redo()` 中,检测当前/上一条记录是否为 TOGGLE 类型,如果是则同步调用 `save_disabled_state`。或者更通用的方案:在 `set()` 之后统一检查 disabled 状态是否有变化并持久化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG 2 [MEDIUM] — `expand_env_vars` 未检测缓冲区截断
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
`src-tauri/src/commands/system.rs:40-58` — `expand_env_vars()`
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
Windows API `ExpandEnvironmentStringsW` 的行为:
|
||||||
|
|
||||||
|
- 第 1 次调用(`lpDst = NULL, nSize = 0`):返回所需缓冲区大小(TCHAR 数)
|
||||||
|
- 第 2 次调用(提供缓冲区):若缓冲区足够,返回写入的 TCHAR 数(≤ nSize);**若缓冲区不足,返回所需大小(> nSize),而非 0**
|
||||||
|
|
||||||
|
当前代码:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let required = unsafe {
|
||||||
|
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
|
||||||
|
};
|
||||||
|
// ...
|
||||||
|
let mut buffer: Vec<u16> = vec![0; required as usize];
|
||||||
|
let result = unsafe {
|
||||||
|
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
// 仅处理了 API 失败
|
||||||
|
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
// result > 0 但可能 > required(截断!)— 未检测
|
||||||
|
```
|
||||||
|
|
||||||
|
### 触发条件(低概率但真实存在)
|
||||||
|
|
||||||
|
环境变量在两次 `ExpandEnvironmentStringsW` 调用之间被修改:
|
||||||
|
1. 第 1 次调用查询到 `%VAR%` 展开后为 50 字符,`required = 51`(含 null)
|
||||||
|
2. 在第 1 次和第 2 次调用之间,另一个进程修改了 `%VAR%` 使其变为 200 字符
|
||||||
|
3. 第 2 次调用时 51 大小的缓冲区不够,API 返回 201
|
||||||
|
4. `result = 201 > 51`,`result != 0`,代码未进入错误分支
|
||||||
|
5. 函数返回截断的不完整路径
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if result == 0 || result > required {
|
||||||
|
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG 3 [MEDIUM] — E2E mock `load_disabled_state` 返回格式与 Rust 端不匹配
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
- `e2e/mocks/ipc.ts:9`
|
||||||
|
- `src-tauri/src/commands/disabled.rs:44` — `load_disabled_state()`
|
||||||
|
- `src/store/app-store.ts:275` — `loadPaths()` 消费端
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
**Rust 端返回类型**:`Result<(Vec<String>, Vec<String>), String>`
|
||||||
|
|
||||||
|
Tauri 将元组序列化为 JSON 数组:`[["disabled_sys_1"], ["disabled_usr_1"]]`
|
||||||
|
|
||||||
|
**Mock 返回**:
|
||||||
|
```javascript
|
||||||
|
case 'load_disabled_state': return { system: [], user: [] };
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^ 这是一个对象!
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端消费**:
|
||||||
|
```typescript
|
||||||
|
const result = await invoke<[string[], string[]]>('load_disabled_state');
|
||||||
|
sysDisabled = result[0]; // 从对象取 → undefined
|
||||||
|
usrDisabled = result[1]; // 从对象取 → undefined
|
||||||
|
new Set(sysDisabled); // → TypeError: undefined is not iterable
|
||||||
|
```
|
||||||
|
|
||||||
|
try/catch 捕获了 TypeError,`sysDisabled`/`usrDisabled` 保持为初始空数组 `[]`。
|
||||||
|
|
||||||
|
### 影响
|
||||||
|
|
||||||
|
- 生产环境无影响(Rust 端正确返回数组)
|
||||||
|
- E2E 测试中**disabled state 加载路径完全未被测试**(被 try/catch 静默跳过)
|
||||||
|
- 如果将来 E2E 测试要覆盖 disabled state 的加载-合并逻辑,会得到错误结果
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
case 'load_disabled_state': return []; // 返回空元组 [[], []]
|
||||||
|
// 或者如果要 mock 有禁用路径的场景:
|
||||||
|
case 'load_disabled_state': return [['C:\\disabled_path'], []];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG 4 [LOW] — `savePaths` 双 hive 失败时丢失错误原因
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
`src/store/app-store.ts:332-336`
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
|
||||||
|
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||||
|
const msg = sysOk ? '用户 PATH 保存失败' :
|
||||||
|
userOk ? '系统 PATH 保存失败' :
|
||||||
|
`保存失败: ${reason}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
当**两个 hive 都保存失败**时:
|
||||||
|
- `sysOk` = false,跳过第 1 个三元分支
|
||||||
|
- `userOk` = false,跳过第 2 个三元分支
|
||||||
|
- `reason` 取的是 `sysResult.reason`(第 1 行),`userResult.reason` 被丢弃
|
||||||
|
- 最终消息:`保存失败: <仅系统 PATH 的错误原因>`
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
||||||
|
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||||
|
const parts = [sysErr, usrErr].filter(Boolean);
|
||||||
|
const msg = parts.length > 0 ? `保存失败: ${parts.join('; ')}` : '保存失败';
|
||||||
|
```
|
||||||
|
|
||||||
|
或者更清晰地分别显示两个 hive 的状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG 5 [LOW] — `handleImportSelect` 导入 both 产生两条 undo 记录
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
`src/hooks/use-app-actions.ts:160-165`
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
||||||
|
const { system, user } = dialogs.importDialog;
|
||||||
|
const flat = flattenImportResult({ system, user }, target);
|
||||||
|
if (flat.system.length > 0)
|
||||||
|
useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
|
||||||
|
if (flat.user.length > 0)
|
||||||
|
useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
|
||||||
|
...
|
||||||
|
}, ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
当用户选择「导入到两者」时:
|
||||||
|
1. 调用 `replacePaths(TargetType.SYSTEM, ...)` → 创建一条 IMPORT undo 记录
|
||||||
|
2. 调用 `replacePaths(TargetType.USER, ...)` → 创建另一条 IMPORT undo 记录
|
||||||
|
|
||||||
|
用户按一次 Ctrl+Z 只能撤销一个 hive 的导入,需要按两次才能完全撤销。
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
在 `app-store.ts` 中新增一个 `replaceBothPaths` 方法,将 system + user 的替换合并为一条 undo 记录,或者把 `replacePaths` 扩展为支持同时替换两个 hive。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 非 Bug 问题(值得关注但暂不紧急)
|
||||||
|
|
||||||
|
| # | 位置 | 描述 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | `PathTable.tsx:35-36` | `validatedRef` / `expandedRef` 随会话持续增长,不清理。Set 中的 key 是路径字符串,正常使用下数量有限(几十到几百),无实际性能影响 |
|
||||||
|
| 2 | `use-keyboard.ts:39-54` | 非管理员模式下 Ctrl+Z/Y/N/S/Delete 静默忽略,用户无任何反馈 |
|
||||||
|
| 3 | `app-store.ts:317` | `backup_registry` 失败时 set statusMessage 为警告,但若后续保存成功,`statusMessage` 被覆盖为「保存成功」,用户看不到备份失败的警告 |
|
||||||
|
| 4 | `MergePreview.tsx:57` | `key={${source}-${displayIndex}}` — key 由翻译后的 source 字符串和 displayIndex 拼接,理论上在极端场景(同一秒内两次渲染翻译变化)可能重复,实际概率极低 |
|
||||||
|
| 5 | `system.rs:44-45` | `ExpandEnvironmentStringsW` 返回 0 时只 `log::warn`,可通过 `GetLastError` 获取具体错误码来改进日志 |
|
||||||
|
| 6 | `disabled.rs:54-56` | `load_disabled_state` 中 `content.trim().is_empty()` 检查在 `serde_json::from_str` 之前,空文件返回空数组。但如果文件包含有效 JSON 空对象 `{}`,反序列化为 `DisabledState::default()` 也正确。逻辑完整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级建议
|
||||||
|
|
||||||
|
| 优先级 | Bug | 理由 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| **P0** | BUG 1 — undo/redo 不持久化 disabled | 用户可感知的数据不一致 |
|
||||||
|
| **P1** | BUG 2 — expand_env_vars 截断 | 概率低但后果是静默数据损坏 |
|
||||||
|
| **P2** | BUG 3 — E2E mock 格式 | 不影响生产,但阻碍 E2E 测试扩展 |
|
||||||
|
| **P3** | BUG 4 — 双失败错误消息 | 边缘场景的 UX 问题 |
|
||||||
|
| **P3** | BUG 5 — 导入双 undo | 用户体验小瑕疵 |
|
||||||
@@ -6,7 +6,7 @@ export function createIpcMock() {
|
|||||||
case 'check_admin': return true;
|
case 'check_admin': return true;
|
||||||
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
|
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
|
||||||
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
|
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
|
||||||
case 'load_disabled_state': return { system: [], user: [] };
|
case 'load_disabled_state': return [[], []];
|
||||||
case 'save_system_paths': return undefined;
|
case 'save_system_paths': return undefined;
|
||||||
case 'save_user_paths': return undefined;
|
case 'save_user_paths': return undefined;
|
||||||
case 'save_disabled_state': return undefined;
|
case 'save_disabled_state': return undefined;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'src-tauri'] },
|
{ ignores: ['dist', 'gui'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
@@ -2271,7 +2271,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "patheditor"
|
name = "patheditor"
|
||||||
version = "4.0.0"
|
version = "5.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "patheditor"
|
name = "patheditor"
|
||||||
version = "4.0.0"
|
|
||||||
description = "Windows PATH Environment Variable Editor"
|
description = "Windows PATH Environment Variable Editor"
|
||||||
authors = ["刘航宇"]
|
version.workspace = true
|
||||||
license = "MIT"
|
edition.workspace = true
|
||||||
repository = "https://github.com/LHY0125/PathEditor"
|
license.workspace = true
|
||||||
edition = "2021"
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -16,14 +16,9 @@ crate-type = ["staticlib", "rlib"]
|
|||||||
tauri-build = { version = "2.6.2", features = [] }
|
tauri-build = { version = "2.6.2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
path-editor-core = { path = "../core" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.11.2", features = [] }
|
tauri = { version = "2.11.2", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-dialog = "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) }
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod registry;
|
|
||||||
pub mod system;
|
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod fs;
|
|
||||||
pub mod disabled;
|
pub mod disabled;
|
||||||
|
pub mod fs;
|
||||||
|
pub mod profiles;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod scanner;
|
||||||
|
pub mod system;
|
||||||
@@ -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() }
|
||||||
@@ -28,6 +28,13 @@ pub fn run() {
|
|||||||
commands::fs::read_text_file,
|
commands::fs::read_text_file,
|
||||||
commands::disabled::save_disabled_state,
|
commands::disabled::save_disabled_state,
|
||||||
commands::disabled::load_disabled_state,
|
commands::disabled::load_disabled_state,
|
||||||
|
commands::scanner::scan_conflicts,
|
||||||
|
commands::scanner::scan_tools,
|
||||||
|
commands::profiles::list_profiles,
|
||||||
|
commands::profiles::save_profile,
|
||||||
|
commands::profiles::load_profile,
|
||||||
|
commands::profiles::delete_profile,
|
||||||
|
commands::profiles::rename_profile,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "PathEditor",
|
"productName": "PathEditor",
|
||||||
"version": "4.0.0",
|
"version": "5.0.0",
|
||||||
"identifier": "com.liuhangyu.patheditor",
|
"identifier": "com.liuhangyu.patheditor",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "PathEditor v4.0",
|
"title": "PathEditor v5.0",
|
||||||
"width": 900,
|
"width": 900,
|
||||||
"height": 700,
|
"height": 700,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patheditor",
|
"name": "patheditor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.0.0",
|
"version": "5.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
|
interface ConflictLocation {
|
||||||
|
dir: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConflictEntry {
|
||||||
|
name: string;
|
||||||
|
locations: ConflictLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolGroup {
|
||||||
|
dir: string;
|
||||||
|
exists: boolean;
|
||||||
|
exes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'conflicts' | 'tools';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyzeDialog({ open, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tab, setTab] = useState<TabType>('conflicts');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [conflicts, setConflicts] = useState<ConflictEntry[]>([]);
|
||||||
|
const [toolGroups, setToolGroups] = useState<ToolGroup[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
const paths = getEnabledPaths();
|
||||||
|
Promise.all([
|
||||||
|
invoke<ConflictEntry[]>('scan_conflicts', { paths }),
|
||||||
|
invoke<ToolGroup[]>('scan_tools', { paths, query: '' }),
|
||||||
|
])
|
||||||
|
.then(([c, t]) => {
|
||||||
|
setConflicts(c);
|
||||||
|
setToolGroups(t);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 搜索的工具清单
|
||||||
|
const filteredTools = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return toolGroups;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return toolGroups
|
||||||
|
.map((g) => ({ ...g, exes: g.exes.filter((e) => e.toLowerCase().includes(q)) }))
|
||||||
|
.filter((g) => g.exes.length > 0);
|
||||||
|
}, [toolGroups, searchQuery]);
|
||||||
|
|
||||||
|
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)' }}>
|
||||||
|
<h2 className="text-base font-semibold">{t('analyze.title')}</h2>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb}
|
||||||
|
onClick={() => setTab(tb)}
|
||||||
|
className="px-3 py-1 text-sm rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: tab === tb ? '#3b82f6' : 'transparent',
|
||||||
|
color: tab === tb ? '#fff' : 'var(--app-fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tb === 'conflicts' ? t('analyze.conflicts') : t('analyze.tools')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<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 }}>
|
||||||
|
{t('analyze.scanning')}
|
||||||
|
</div>
|
||||||
|
) : tab === 'conflicts' ? (
|
||||||
|
<ConflictsTab conflicts={conflicts} />
|
||||||
|
) : (
|
||||||
|
<ToolsTab groups={filteredTools} query={searchQuery} onQueryChange={setSearchQuery} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConflictsTab({ conflicts }: { conflicts: ConflictEntry[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
return <EmptyHint text={t('analyze.noConflicts')} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm mb-2" style={{ color: 'var(--app-fg)', opacity: 0.7 }}>
|
||||||
|
{t('analyze.conflictCount', { count: conflicts.length })}
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium">EXE</th>
|
||||||
|
<th className="text-left py-1.5 font-medium">{t('analyze.priority')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{conflicts.map((c) => (
|
||||||
|
<tr key={c.name} className="border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{c.name}</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
{c.locations.map((loc, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-xs py-0.5"
|
||||||
|
style={{ color: i === 0 ? '#22c55e' : '#ef4444' }}
|
||||||
|
>
|
||||||
|
{i === 0 ? '✓' : '✗'} {loc.dir}
|
||||||
|
{i > 0 && (
|
||||||
|
<span className="ml-1" style={{ opacity: 0.5 }}>
|
||||||
|
({t('analyze.shadowed')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolsTab({
|
||||||
|
groups,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
}: {
|
||||||
|
groups: ToolGroup[];
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder={t('analyze.searchPlaceholder')}
|
||||||
|
className="w-full px-3 py-1.5 text-sm rounded mb-3 border outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--app-list-bg)',
|
||||||
|
color: 'var(--app-fg)',
|
||||||
|
borderColor: 'var(--app-border)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<EmptyHint text={t('analyze.noTools')} />
|
||||||
|
) : (
|
||||||
|
groups.map((g) => (
|
||||||
|
<div key={g.dir} className="mb-3">
|
||||||
|
<div
|
||||||
|
className="text-xs font-mono py-1 px-2 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: g.exists ? 'transparent' : 'rgba(239,68,68,0.1)',
|
||||||
|
color: g.exists ? 'var(--app-fg)' : '#ef4444',
|
||||||
|
opacity: g.exists ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.dir} {!g.exists && '(不存在)'}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1 ml-2">
|
||||||
|
{g.exes.map((exe) => (
|
||||||
|
<span
|
||||||
|
key={exe}
|
||||||
|
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
|
||||||
|
>
|
||||||
|
{exe}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHint({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-sm" style={{ color: 'var(--app-fg)', opacity: 0.5 }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnabledPaths(): string[] {
|
||||||
|
const { sysPaths, userPaths } = useAppStore.getState();
|
||||||
|
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
|
|
||||||
|
interface ProfileMeta {
|
||||||
|
name: string;
|
||||||
|
created: string;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileData {
|
||||||
|
name: string;
|
||||||
|
sys: PathEntry[];
|
||||||
|
user: PathEntry[];
|
||||||
|
created: string;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileDialog({ open, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [profiles, setProfiles] = useState<ProfileMeta[]>([]);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [selectedData, setSelectedData] = useState<ProfileData | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
|
const refreshProfiles = useCallback(async () => {
|
||||||
|
const list = await invoke<ProfileMeta[]>('list_profiles');
|
||||||
|
setProfiles(list);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) refreshProfiles();
|
||||||
|
}, [open, refreshProfiles]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const { sysPaths, userPaths } = useAppStore.getState();
|
||||||
|
await invoke('save_profile', { name: newName.trim(), sys: sysPaths, user: userPaths });
|
||||||
|
setNewName('');
|
||||||
|
setSaving(false);
|
||||||
|
refreshProfiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = async (name: string) => {
|
||||||
|
const data = await invoke<ProfileData>('load_profile', { name });
|
||||||
|
setSelected(name);
|
||||||
|
setSelectedData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
// 同步 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),
|
||||||
|
});
|
||||||
|
await useAppStore.getState().savePaths();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (name: string) => {
|
||||||
|
if (!window.confirm(`删除配置文件 "${name}"?`)) return;
|
||||||
|
await invoke('delete_profile', { name });
|
||||||
|
if (selected === name) { setSelected(null); setSelectedData(null); }
|
||||||
|
refreshProfiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!selected || !renameValue.trim()) return;
|
||||||
|
await invoke('rename_profile', { oldName: selected, newName: renameValue.trim() });
|
||||||
|
setRenameOpen(false);
|
||||||
|
setSelected(renameValue.trim());
|
||||||
|
refreshProfiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
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)' }}>
|
||||||
|
<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)}
|
||||||
|
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)' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-sm rounded text-white"
|
||||||
|
style={{ backgroundColor: '#3b82f6' }}
|
||||||
|
disabled={saving || !newName.trim()}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{t('profile.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 text-sm rounded hover:opacity-70 transition-opacity"
|
||||||
|
style={{ color: 'var(--app-fg)' }}
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 左侧:列表 */}
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
profiles.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
onClick={() => handleLoad(p.name)}
|
||||||
|
className="px-2 py-1.5 text-sm rounded cursor-pointer mb-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: selected === p.name ? 'rgba(59,130,246,0.15)' : 'transparent',
|
||||||
|
color: selected === p.name ? '#3b82f6' : 'var(--app-fg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:详情 */}
|
||||||
|
<div className="flex-1 p-3 overflow-auto">
|
||||||
|
{!selectedData ? (
|
||||||
|
<div className="text-center py-10 text-sm" style={{ opacity: 0.4 }}>
|
||||||
|
{profiles.length === 0 ? t('profile.noProfiles') : '选择一个配置文件'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-3">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-xs rounded text-white"
|
||||||
|
style={{ backgroundColor: '#3b82f6' }}
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
{t('profile.apply')}
|
||||||
|
</button>
|
||||||
|
<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); }}
|
||||||
|
>
|
||||||
|
{t('profile.rename')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-xs rounded text-white"
|
||||||
|
style={{ backgroundColor: '#ef4444' }}
|
||||||
|
onClick={() => handleDelete(selectedData.name)}
|
||||||
|
>
|
||||||
|
{t('profile.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renameOpen && (
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
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)' }}
|
||||||
|
/>
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{paths.length === 0 ? (
|
||||||
|
<div className="text-xs" style={{ opacity: 0.4 }}>(空)</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5 max-h-48 overflow-auto">
|
||||||
|
{paths.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-xs font-mono px-2 py-0.5 rounded flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--app-list-bg)',
|
||||||
|
color: e.enabled ? 'var(--app-fg)' : '#ef4444',
|
||||||
|
textDecoration: e.enabled ? 'none' : 'line-through',
|
||||||
|
opacity: e.enabled ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: e.enabled ? '#22c55e' : '#ef4444', fontSize: 10 }}>
|
||||||
|
{e.enabled ? '●' : '○'}
|
||||||
|
</span>
|
||||||
|
{e.path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { MergePreview } from '@/components/path-list/MergePreview';
|
|||||||
import { PathEditDialog } from '@/components/dialogs/PathEditDialog';
|
import { PathEditDialog } from '@/components/dialogs/PathEditDialog';
|
||||||
import { HelpDialog } from '@/components/dialogs/HelpDialog';
|
import { HelpDialog } from '@/components/dialogs/HelpDialog';
|
||||||
import { ImportDialog } from '@/components/dialogs/ImportDialog';
|
import { ImportDialog } from '@/components/dialogs/ImportDialog';
|
||||||
|
import { AnalyzeDialog } from '@/components/dialogs/AnalyzeDialog';
|
||||||
|
import { ProfileDialog } from '@/components/dialogs/ProfileDialog';
|
||||||
import { useAppActions, type DialogState } from '@/hooks/use-app-actions';
|
import { useAppActions, type DialogState } from '@/hooks/use-app-actions';
|
||||||
|
|
||||||
/** Tauri's File object includes the native filesystem path */
|
/** Tauri's File object includes the native filesystem path */
|
||||||
@@ -33,10 +35,12 @@ export function AppShell() {
|
|||||||
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
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, {
|
const actions = useAppActions(activeTab, {
|
||||||
editDialog, newDialog, helpOpen, importDialog,
|
editDialog, newDialog, helpOpen, importDialog,
|
||||||
setEditDialog, setNewDialog, setHelpOpen, setImportDialog,
|
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabConfig: { id: TabId; label: string }[] = [
|
const tabConfig: { id: TabId; label: string }[] = [
|
||||||
@@ -84,6 +88,8 @@ export function AppShell() {
|
|||||||
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
||||||
i18n.changeLanguage(current === 'zh-CN' ? 'en' : 'zh-CN');
|
i18n.changeLanguage(current === 'zh-CN' ? 'en' : 'zh-CN');
|
||||||
}}
|
}}
|
||||||
|
onProfiles={() => setProfilesOpen(true)}
|
||||||
|
onAnalyze={() => setAnalyzeOpen(true)}
|
||||||
onDarkMode={() => useThemeStore.getState().toggle()}
|
onDarkMode={() => useThemeStore.getState().toggle()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +118,8 @@ export function AppShell() {
|
|||||||
<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={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)} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
className="cursor-pointer select-none"
|
className="cursor-pointer select-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? 'rgba(59, 130, 246, 0.3)'
|
? 'var(--app-select-row)'
|
||||||
: rowIdx % 2 === 0
|
: rowIdx % 2 === 0
|
||||||
? 'var(--app-list-bg)'
|
? 'var(--app-list-bg)'
|
||||||
: 'var(--app-list-alt)',
|
: 'var(--app-list-alt)',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface ToolBarProps {
|
|||||||
onHelp: () => void;
|
onHelp: () => void;
|
||||||
onLanguage: () => void;
|
onLanguage: () => void;
|
||||||
onDarkMode: () => void;
|
onDarkMode: () => void;
|
||||||
|
onAnalyze: () => void;
|
||||||
|
onProfiles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolBar(props: ToolBarProps) {
|
export function ToolBar(props: ToolBarProps) {
|
||||||
@@ -66,6 +68,12 @@ export function ToolBar(props: ToolBarProps) {
|
|||||||
<button className={btnClass} style={btnStyle} onClick={props.onLanguage}>
|
<button className={btnClass} style={btnStyle} onClick={props.onLanguage}>
|
||||||
{t('button.language')}
|
{t('button.language')}
|
||||||
</button>
|
</button>
|
||||||
|
<button className={btnClass} style={btnStyle} onClick={props.onAnalyze}>
|
||||||
|
{t('button.analyze')}
|
||||||
|
</button>
|
||||||
|
<button className={btnClass} style={btnStyle} onClick={props.onProfiles}>
|
||||||
|
{t('button.profiles')}
|
||||||
|
</button>
|
||||||
<button className={btnClass} style={btnStyle} onClick={props.onDarkMode}>
|
<button className={btnClass} style={btnStyle} onClick={props.onDarkMode}>
|
||||||
{t('button.darkMode')}
|
{t('button.darkMode')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { PathEntry } from './path-entry';
|
import type { PathEntry } from './path-entry';
|
||||||
|
|
||||||
export const OperationType = {
|
export const OperationType = {
|
||||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8,
|
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8, IMPORT_BOTH: 9,
|
||||||
} as const;
|
} as const;
|
||||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@ export interface OpRecord {
|
|||||||
newPaths: PathEntry[];
|
newPaths: PathEntry[];
|
||||||
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
|
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
|
||||||
indices?: number[];
|
indices?: number[];
|
||||||
|
/** IMPORT_BOTH 专用:用户 hive 的旧路径 */
|
||||||
|
oldPathsOther?: PathEntry[];
|
||||||
|
/** IMPORT_BOTH 专用:用户 hive 的新路径 */
|
||||||
|
newPathsOther?: PathEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_SIZE = 50;
|
const DEFAULT_MAX_SIZE = 50;
|
||||||
@@ -88,6 +92,12 @@ export class UndoRedoManager {
|
|||||||
case OperationType.TOGGLE:
|
case OperationType.TOGGLE:
|
||||||
target[rec.index] = rec.oldPaths[0];
|
target[rec.index] = rec.oldPaths[0];
|
||||||
break;
|
break;
|
||||||
|
case OperationType.IMPORT_BOTH:
|
||||||
|
sys.length = 0;
|
||||||
|
sys.push(...rec.oldPaths);
|
||||||
|
user.length = 0;
|
||||||
|
user.push(...(rec.oldPathsOther || []));
|
||||||
|
return [sys, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [sys, user];
|
return [sys, user];
|
||||||
@@ -138,6 +148,12 @@ export class UndoRedoManager {
|
|||||||
case OperationType.TOGGLE:
|
case OperationType.TOGGLE:
|
||||||
target[rec.index] = rec.newPaths[0];
|
target[rec.index] = rec.newPaths[0];
|
||||||
break;
|
break;
|
||||||
|
case OperationType.IMPORT_BOTH:
|
||||||
|
sys.length = 0;
|
||||||
|
sys.push(...rec.newPaths);
|
||||||
|
user.length = 0;
|
||||||
|
user.push(...(rec.newPathsOther || []));
|
||||||
|
return [sys, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [sys, user];
|
return [sys, user];
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function join_path(paths: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 分割 PATH 字符串。
|
/** 分割 PATH 字符串。
|
||||||
* 注意:Rust 端 src-tauri/src/commands/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
|
* 注意:Rust 端 core/src/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
|
||||||
export function split_path(raw: string): string[] {
|
export function split_path(raw: string): string[] {
|
||||||
return raw
|
return raw
|
||||||
.split(';')
|
.split(';')
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface DialogState {
|
|||||||
setNewDialog: (v: boolean) => void;
|
setNewDialog: (v: boolean) => void;
|
||||||
setHelpOpen: (v: boolean) => void;
|
setHelpOpen: (v: boolean) => void;
|
||||||
setImportDialog: (v: DialogState['importDialog']) => void;
|
setImportDialog: (v: DialogState['importDialog']) => void;
|
||||||
|
setAnalyzeOpen: (v: boolean) => void;
|
||||||
|
setProfilesOpen: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||||
@@ -160,8 +162,12 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
||||||
const { system, user } = dialogs.importDialog;
|
const { system, user } = dialogs.importDialog;
|
||||||
const flat = flattenImportResult({ system, user }, target);
|
const flat = flattenImportResult({ system, user }, target);
|
||||||
|
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
|
||||||
|
useAppStore.getState().replaceBothPaths(flat.system.map(e => e.path), flat.user.map(e => e.path));
|
||||||
|
} else {
|
||||||
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
|
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
|
||||||
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
|
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
|
||||||
|
}
|
||||||
setImportDialog({ open: false, system: [], user: [] });
|
setImportDialog({ open: false, system: [], user: [] });
|
||||||
}, [dialogs.importDialog, setImportDialog]);
|
}, [dialogs.importDialog, setImportDialog]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"save": "OK",
|
"save": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
"analyze": "Analyze",
|
||||||
|
"profiles": "Profiles",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"readonly": "Read-only mode — Administrator privileges required for editing",
|
"readonly": "Read-only mode — Administrator privileges required for editing",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Saved successfully",
|
"saved": "Saved successfully",
|
||||||
|
"saved_without_backup": "Saved (backup failed)",
|
||||||
"error": "Operation failed",
|
"error": "Operation failed",
|
||||||
"warning_backup": "Backup creation failed, save will proceed without backup",
|
"warning_backup": "Backup creation failed, save will proceed without backup",
|
||||||
"deleted": "Deleted {{count}} path(s)",
|
"deleted": "Deleted {{count}} path(s)",
|
||||||
@@ -69,6 +72,31 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"search": "Search paths..."
|
"search": "Search paths..."
|
||||||
},
|
},
|
||||||
|
"analyze": {
|
||||||
|
"title": "PATH Analysis",
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"tools": "Tools",
|
||||||
|
"scanning": "Scanning...",
|
||||||
|
"noConflicts": "No executable conflicts found",
|
||||||
|
"noTools": "No matching executables found",
|
||||||
|
"priority": "Prioritized",
|
||||||
|
"shadowed": "Shadowed",
|
||||||
|
"searchPlaceholder": "Search executable name...",
|
||||||
|
"conflictCount": "{{count}} file conflict(s) found"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "PATH Profiles",
|
||||||
|
"saveCurrent": "Save Current as Profile",
|
||||||
|
"namePlaceholder": "Profile name...",
|
||||||
|
"save": "Save",
|
||||||
|
"load": "Load",
|
||||||
|
"apply": "Apply",
|
||||||
|
"delete": "Delete",
|
||||||
|
"rename": "Rename",
|
||||||
|
"noProfiles": "No saved profiles",
|
||||||
|
"applyConfirm": "This will overwrite current PATH with profile \"{{name}}\" and write to registry. Confirm?",
|
||||||
|
"deleted": "Profile \"{{name}}\" deleted"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
|
"content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"save": "确定",
|
"save": "确定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
|
"analyze": "分析",
|
||||||
|
"profiles": "配置",
|
||||||
"undo": "撤销",
|
"undo": "撤销",
|
||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"readonly": "只读模式 — 需要管理员权限才能编辑",
|
"readonly": "只读模式 — 需要管理员权限才能编辑",
|
||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"saved": "保存成功",
|
"saved": "保存成功",
|
||||||
|
"saved_without_backup": "保存成功(备份失败)",
|
||||||
"error": "操作失败",
|
"error": "操作失败",
|
||||||
"warning_backup": "备份创建失败,保存将继续但不生成备份",
|
"warning_backup": "备份创建失败,保存将继续但不生成备份",
|
||||||
"deleted": "已删除 {{count}} 个路径",
|
"deleted": "已删除 {{count}} 个路径",
|
||||||
@@ -69,6 +72,31 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"search": "搜索路径..."
|
"search": "搜索路径..."
|
||||||
},
|
},
|
||||||
|
"analyze": {
|
||||||
|
"title": "PATH 分析",
|
||||||
|
"conflicts": "冲突检测",
|
||||||
|
"tools": "工具清单",
|
||||||
|
"scanning": "正在扫描...",
|
||||||
|
"noConflicts": "未发现可执行文件冲突",
|
||||||
|
"noTools": "未找到匹配的可执行文件",
|
||||||
|
"priority": "优先执行",
|
||||||
|
"shadowed": "被遮蔽",
|
||||||
|
"searchPlaceholder": "搜索可执行文件名...",
|
||||||
|
"conflictCount": "发现 {{count}} 个文件冲突"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "PATH 配置文件",
|
||||||
|
"saveCurrent": "保存当前 PATH 为配置",
|
||||||
|
"namePlaceholder": "配置名称...",
|
||||||
|
"save": "保存",
|
||||||
|
"load": "加载",
|
||||||
|
"apply": "应用",
|
||||||
|
"delete": "删除",
|
||||||
|
"rename": "重命名",
|
||||||
|
"noProfiles": "暂无配置文件",
|
||||||
|
"applyConfirm": "将用配置 \"{{name}}\" 覆盖当前 PATH 并写入注册表,确定吗?",
|
||||||
|
"deleted": "已删除配置 \"{{name}}\""
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
|
"content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface AppState {
|
|||||||
moveDown: (index: number, target: TargetType) => void;
|
moveDown: (index: number, target: TargetType) => void;
|
||||||
cleanPaths: (target: TargetType, validateFn: (p: string) => boolean) => string[];
|
cleanPaths: (target: TargetType, validateFn: (p: string) => boolean) => string[];
|
||||||
replacePaths: (target: TargetType, newPaths: string[]) => void;
|
replacePaths: (target: TargetType, newPaths: string[]) => void;
|
||||||
|
replaceBothPaths: (sysPaths: string[], userPaths: string[]) => void;
|
||||||
clearPaths: (target: TargetType) => void;
|
clearPaths: (target: TargetType) => void;
|
||||||
|
|
||||||
togglePath: (index: number, target: TargetType) => void;
|
togglePath: (index: number, target: TargetType) => void;
|
||||||
@@ -195,6 +196,20 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
replaceBothPaths: (sysPaths, userPaths) => {
|
||||||
|
const state = get();
|
||||||
|
const sysEntries: PathEntry[] = sysPaths.map(p => ({ path: p, enabled: true }));
|
||||||
|
const usrEntries: PathEntry[] = userPaths.map(p => ({ path: p, enabled: true }));
|
||||||
|
state.undoRedo.push({
|
||||||
|
type: OperationType.IMPORT_BOTH, target: TargetType.SYSTEM, index: 0,
|
||||||
|
count: sysEntries.length + usrEntries.length,
|
||||||
|
oldPaths: [...state.sysPaths], newPaths: [...sysEntries],
|
||||||
|
oldPathsOther: [...state.userPaths], newPathsOther: [...usrEntries],
|
||||||
|
});
|
||||||
|
set({ sysPaths: [...sysEntries], userPaths: [...usrEntries], selectedIndices: [] });
|
||||||
|
markDirty();
|
||||||
|
},
|
||||||
|
|
||||||
clearPaths: (target) => {
|
clearPaths: (target) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
@@ -245,6 +260,11 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
});
|
});
|
||||||
|
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||||
|
invoke('save_disabled_state', {
|
||||||
|
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
||||||
|
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -257,6 +277,11 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
});
|
});
|
||||||
|
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||||
|
invoke('save_disabled_state', {
|
||||||
|
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
||||||
|
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -314,8 +339,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||||
|
let backupFailed = false;
|
||||||
await invoke('backup_registry', { customDir: null })
|
await invoke('backup_registry', { customDir: null })
|
||||||
.catch(() => set({ statusMessage: i18n.t('status.warning_backup') }));
|
.catch(() => { backupFailed = true; });
|
||||||
|
|
||||||
const [sysResult, userResult] = await Promise.allSettled([
|
const [sysResult, userResult] = await Promise.allSettled([
|
||||||
invoke('save_system_paths', { paths: sysPaths }),
|
invoke('save_system_paths', { paths: sysPaths }),
|
||||||
@@ -328,11 +354,14 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
if (sysOk && userOk) {
|
if (sysOk && userOk) {
|
||||||
invoke('broadcast_env_change').catch(() => {});
|
invoke('broadcast_env_change').catch(() => {});
|
||||||
const savedSys = [...state.sysPaths], savedUser = [...state.userPaths];
|
const savedSys = [...state.sysPaths], savedUser = [...state.userPaths];
|
||||||
set({ isModified: false, isSaving: false, statusMessage: i18n.t('status.saved'), _savedSys: savedSys, _savedUser: savedUser });
|
set({ isModified: false, isSaving: false,
|
||||||
|
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
|
||||||
|
_savedSys: savedSys, _savedUser: savedUser });
|
||||||
} else {
|
} else {
|
||||||
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
|
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
||||||
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||||
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${reason}`;
|
const parts = [sysErr, usrErr].filter(Boolean);
|
||||||
|
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`;
|
||||||
set({ isSaving: false, statusMessage: msg });
|
set({ isSaving: false, statusMessage: msg });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ body {
|
|||||||
--app-fg: var(--color-light-fg);
|
--app-fg: var(--color-light-fg);
|
||||||
--app-border: var(--color-light-border);
|
--app-border: var(--color-light-border);
|
||||||
--app-hover: var(--color-light-hover);
|
--app-hover: var(--color-light-hover);
|
||||||
|
--app-select-row: rgba(59, 130, 246, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式 */
|
/* 深色模式 */
|
||||||
@@ -51,6 +52,7 @@ body {
|
|||||||
--app-fg: var(--color-dark-fg);
|
--app-fg: var(--color-dark-fg);
|
||||||
--app-border: var(--color-dark-border);
|
--app-border: var(--color-dark-border);
|
||||||
--app-hover: var(--color-dark-hover);
|
--app-hover: var(--color-dark-hover);
|
||||||
|
--app-select-row: rgba(96, 165, 250, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式 */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
// Mock @tauri-apps/api/core
|
// Mock @tauri-apps/api/core
|
||||||
vi.mock('@tauri-apps/api/core', () => ({
|
vi.mock('@tauri-apps/api/core', () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock i18n
|
// Mock i18n
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
exclude: ['e2e/**', 'node_modules/**', 'src-tauri/**'],
|
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||