Compare commits

..

9 Commits

Author SHA1 Message Date
Serendipity 26f6953919 fix: ProfileDialog 标题栏添加 ✕ 关闭按钮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:29:50 +08:00
Serendipity 5ed15535e7 fix: 深色模式下选中行对比度不足 — 新增 CSS 变量分别适配浅色/深色主题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:25:54 +08:00
Serendipity 230fb5d741 fix: 配置文件目录从 %APPDATA% 改为 %USERPROFILE%/.patheditor/profiles
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:20:38 +08:00
Serendipity d7d11480b8 feat: PATH 配置文件/预设切换 — 保存、加载、一键应用不同场景的 PATH 配置
- 新增 profiles.rs: list/save/load/delete/rename 五个 Rust 命令
- 配置文件存储在 %APPDATA%/.patheditor/profiles/<name>.json
- ProfileDialog: 保存当前 PATH、加载预览、一键应用到注册表
- 工具栏新增「配置」按钮

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:38:32 +08:00
Serendipity d7bc752b84 fix: release workflow 兼容已存在的 release + 版本号升到 4.2.0
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:54:52 +08:00
28 changed files with 1239 additions and 41 deletions
+27
View File
@@ -0,0 +1,27 @@
---
name: Bug 报告
about: 提交问题报告帮助改进 PathEditor
title: "[Bug] "
labels: bug
assignees: ''
---
## 问题描述
<!-- 清晰描述 bug 是什么 -->
## 复现步骤
1.
2.
3.
## 期望行为
## 截图(如有)
## 系统信息
- Windows 版本:
- 是否管理员:
- PathEditor 版本:
+15
View File
@@ -0,0 +1,15 @@
---
name: 功能建议
about: 建议新功能或改进
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 使用场景
<!-- 你会在什么场景下需要这个功能? -->
## 建议方案
<!-- 你期望的功能是什么样的? -->
+5 -1
View File
@@ -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 "src-tauri\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 }}
+51 -12
View File
@@ -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 UIReact 19 + Tailwind CSS 4 + Zustand
- 现代 Web UIReact + 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)。
+29
View File
@@ -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#开发)。
+7 -6
View File
@@ -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-4.2.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,7 +20,7 @@
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。 PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
v4.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。 v4.2 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
## 截图 ## 截图
@@ -41,7 +42,7 @@ _[待补充]_
- 环境变量路径(含 `%VAR%`)悬浮展开预览 - 环境变量路径(含 `%VAR%`)悬浮展开预览
### 撤销/重做 ### 撤销/重做
- 支持 8 种操作类型,最多 50 步历史 - 支持 9 种操作类型,最多 50 步历史
- 新增、删除、编辑、移动、清理、清空、导入均可撤销 - 新增、删除、编辑、移动、清理、清空、导入均可撤销
### 导入/导出 ### 导入/导出
@@ -63,7 +64,7 @@ _[待补充]_
## 安装 ## 安装
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.0.0_x64-setup.exe` 安装。 从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.2.0_x64-setup.exe` 安装。
或从源码构建: 或从源码构建:
@@ -106,7 +107,7 @@ cd src-tauri && cargo test
| 国际化 | i18next | | 国际化 | i18next |
| 桌面框架 | Tauri 2.x | | 桌面框架 | Tauri 2.x |
| 后端 | Rust (winreg + windows-rs FFI) | | 后端 | Rust (winreg + windows-rs FFI) |
| 前端测试 | Vitest (45 个测试) | | 前端测试 | Vitest (72 个测试) |
| Rust 测试 | cargo test (10 个测试) | | Rust 测试 | cargo test (10 个测试) |
| 构建 | Vite | | 构建 | Vite |
| 打包 | NSIS | | 打包 | NSIS |
+233
View File
@@ -0,0 +1,233 @@
# PathEditor v4.2 代码审查 — 隐含 Bug 分析报告
> 审查日期:2026-05-28 | 审查范围:全部前端核心模块 + Rust 后端 + E2E 测试
---
## BUG 1 [HIGH] — undo/redo TOGGLE 后不持久化 disabled 状态
### 位置
`src/store/app-store.ts``togglePath()` (L213-237) vs `undo()` (L239-248) / `redo()` (L251-260)
### 现象
1. 用户勾选复选框禁用一个路径 → `togglePath` 立即调用 `invoke('save_disabled_state', ...)` 写入 `disabled.json`
2. 用户按 Ctrl+Z 撤销 → `undo()` 恢复内存中的 `enabled` 状态,但**不调用** `save_disabled_state`
3. `disabled.json` 中该路径仍标记为 disabled
4. 下次启动 `loadPaths` 时,从 `disabled.json` 读到的是旧数据,路径又被标记为 disabled
### 根因
`undo()``redo()` 中处理 TOGGLE 时,只更新了 Zustand store 中的 `sysPaths`/`userPaths`,没有像 `togglePath` 那样同步持久化 disabled 状态。
```typescript
// togglePath — 有持久化
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }).catch(() => {});
// undo/redo — 没有持久化!
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
});
```
### 影响
- 撤销 toggle 后刷新/重启应用,disabled 状态恢复为撤销前的值
- 数据和 UI 展示不一致
### 修复方向
`undo()``redo()` 中,检测当前/上一条记录是否为 TOGGLE 类型,如果是则同步调用 `save_disabled_state`。或者更通用的方案:在 `set()` 之后统一检查 disabled 状态是否有变化并持久化。
---
## BUG 2 [MEDIUM] — `expand_env_vars` 未检测缓冲区截断
### 位置
`src-tauri/src/commands/system.rs:40-58``expand_env_vars()`
### 现象
Windows API `ExpandEnvironmentStringsW` 的行为:
- 第 1 次调用(`lpDst = NULL, nSize = 0`):返回所需缓冲区大小(TCHAR 数)
- 第 2 次调用(提供缓冲区):若缓冲区足够,返回写入的 TCHAR 数(≤ nSize);**若缓冲区不足,返回所需大小(> nSize),而非 0**
当前代码:
```rust
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
// ...
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 {
// 仅处理了 API 失败
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
return path.to_string();
}
// result > 0 但可能 > required(截断!)— 未检测
```
### 触发条件(低概率但真实存在)
环境变量在两次 `ExpandEnvironmentStringsW` 调用之间被修改:
1. 第 1 次调用查询到 `%VAR%` 展开后为 50 字符,`required = 51`(含 null
2. 在第 1 次和第 2 次调用之间,另一个进程修改了 `%VAR%` 使其变为 200 字符
3. 第 2 次调用时 51 大小的缓冲区不够,API 返回 201
4. `result = 201 > 51``result != 0`,代码未进入错误分支
5. 函数返回截断的不完整路径
### 修复方向
```rust
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
return path.to_string();
}
```
---
## BUG 3 [MEDIUM] — E2E mock `load_disabled_state` 返回格式与 Rust 端不匹配
### 位置
- `e2e/mocks/ipc.ts:9`
- `src-tauri/src/commands/disabled.rs:44``load_disabled_state()`
- `src/store/app-store.ts:275``loadPaths()` 消费端
### 现象
**Rust 端返回类型**`Result<(Vec<String>, Vec<String>), String>`
Tauri 将元组序列化为 JSON 数组:`[["disabled_sys_1"], ["disabled_usr_1"]]`
**Mock 返回**
```javascript
case 'load_disabled_state': return { system: [], user: [] };
// ^^^^^^^^^^^^^^^^^^^^^^^^ 这是一个对象!
```
**前端消费**
```typescript
const result = await invoke<[string[], string[]]>('load_disabled_state');
sysDisabled = result[0]; // 从对象取 → undefined
usrDisabled = result[1]; // 从对象取 → undefined
new Set(sysDisabled); // → TypeError: undefined is not iterable
```
try/catch 捕获了 TypeError`sysDisabled`/`usrDisabled` 保持为初始空数组 `[]`
### 影响
- 生产环境无影响(Rust 端正确返回数组)
- E2E 测试中**disabled state 加载路径完全未被测试**(被 try/catch 静默跳过)
- 如果将来 E2E 测试要覆盖 disabled state 的加载-合并逻辑,会得到错误结果
### 修复方向
```javascript
case 'load_disabled_state': return []; // 返回空元组 [[], []]
// 或者如果要 mock 有禁用路径的场景:
case 'load_disabled_state': return [['C:\\disabled_path'], []];
```
---
## BUG 4 [LOW] — `savePaths` 双 hive 失败时丢失错误原因
### 位置
`src/store/app-store.ts:332-336`
### 现象
```typescript
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
const msg = sysOk ? '用户 PATH 保存失败' :
userOk ? '系统 PATH 保存失败' :
`保存失败: ${reason}`;
```
当**两个 hive 都保存失败**时:
- `sysOk` = false,跳过第 1 个三元分支
- `userOk` = false,跳过第 2 个三元分支
- `reason` 取的是 `sysResult.reason`(第 1 行),`userResult.reason` 被丢弃
- 最终消息:`保存失败: <仅系统 PATH 的错误原因>`
### 修复方向
```typescript
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
const parts = [sysErr, usrErr].filter(Boolean);
const msg = parts.length > 0 ? `保存失败: ${parts.join('; ')}` : '保存失败';
```
或者更清晰地分别显示两个 hive 的状态。
---
## BUG 5 [LOW] — `handleImportSelect` 导入 both 产生两条 undo 记录
### 位置
`src/hooks/use-app-actions.ts:160-165`
### 现象
```typescript
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
const { system, user } = dialogs.importDialog;
const flat = flattenImportResult({ system, user }, target);
if (flat.system.length > 0)
useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
if (flat.user.length > 0)
useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
...
}, ...);
```
当用户选择「导入到两者」时:
1. 调用 `replacePaths(TargetType.SYSTEM, ...)` → 创建一条 IMPORT undo 记录
2. 调用 `replacePaths(TargetType.USER, ...)` → 创建另一条 IMPORT undo 记录
用户按一次 Ctrl+Z 只能撤销一个 hive 的导入,需要按两次才能完全撤销。
### 修复方向
`app-store.ts` 中新增一个 `replaceBothPaths` 方法,将 system + user 的替换合并为一条 undo 记录,或者把 `replacePaths` 扩展为支持同时替换两个 hive。
---
## 非 Bug 问题(值得关注但暂不紧急)
| # | 位置 | 描述 |
|---|------|------|
| 1 | `PathTable.tsx:35-36` | `validatedRef` / `expandedRef` 随会话持续增长,不清理。Set 中的 key 是路径字符串,正常使用下数量有限(几十到几百),无实际性能影响 |
| 2 | `use-keyboard.ts:39-54` | 非管理员模式下 Ctrl+Z/Y/N/S/Delete 静默忽略,用户无任何反馈 |
| 3 | `app-store.ts:317` | `backup_registry` 失败时 set statusMessage 为警告,但若后续保存成功,`statusMessage` 被覆盖为「保存成功」,用户看不到备份失败的警告 |
| 4 | `MergePreview.tsx:57` | `key={${source}-${displayIndex}}` — key 由翻译后的 source 字符串和 displayIndex 拼接,理论上在极端场景(同一秒内两次渲染翻译变化)可能重复,实际概率极低 |
| 5 | `system.rs:44-45` | `ExpandEnvironmentStringsW` 返回 0 时只 `log::warn`,可通过 `GetLastError` 获取具体错误码来改进日志 |
| 6 | `disabled.rs:54-56` | `load_disabled_state``content.trim().is_empty()` 检查在 `serde_json::from_str` 之前,空文件返回空数组。但如果文件包含有效 JSON 空对象 `{}`,反序列化为 `DisabledState::default()` 也正确。逻辑完整 |
---
## 修复优先级建议
| 优先级 | Bug | 理由 |
|--------|-----|------|
| **P0** | BUG 1 — undo/redo 不持久化 disabled | 用户可感知的数据不一致 |
| **P1** | BUG 2 — expand_env_vars 截断 | 概率低但后果是静默数据损坏 |
| **P2** | BUG 3 — E2E mock 格式 | 不影响生产,但阻碍 E2E 测试扩展 |
| **P3** | BUG 4 — 双失败错误消息 | 边缘场景的 UX 问题 |
| **P3** | BUG 5 — 导入双 undo | 用户体验小瑕疵 |
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "patheditor", "name": "patheditor",
"private": true, "private": true,
"version": "4.0.0", "version": "4.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "patheditor" name = "patheditor"
version = "4.0.0" version = "4.2.0"
description = "Windows PATH Environment Variable Editor" description = "Windows PATH Environment Variable Editor"
authors = ["刘航宇"] authors = ["刘航宇"]
license = "MIT" license = "MIT"
+5 -3
View File
@@ -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;
+146
View File
@@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
#[tauri::command]
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
#[tauri::command]
pub fn save_profile(
name: String,
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let data = ProfileData {
name,
sys,
user,
created: now.clone(),
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
#[tauri::command]
pub fn load_profile(name: String) -> Result<ProfileData, String> {
let path = profile_path(&name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
#[tauri::command]
pub fn delete_profile(name: String) -> Result<(), String> {
let path = profile_path(&name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
#[tauri::command]
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> {
let old_path = profile_path(&old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.clone();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(&new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"];
#[derive(serde::Serialize, Clone)]
pub struct ConflictLocation {
pub dir: String,
pub priority: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct ConflictEntry {
pub name: String,
pub locations: Vec<ConflictLocation>,
}
#[derive(serde::Serialize)]
pub struct ToolGroup {
pub dir: String,
pub exists: bool,
pub exes: Vec<String>,
}
/// 扫描 PATH 中的可执行文件冲突
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
#[tauri::command]
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
for (priority, dir) in paths.iter().enumerate() {
let p = Path::new(dir);
if !p.is_dir() {
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
let key = name.to_lowercase();
map.entry(key).or_default().push((priority, dir.clone()));
}
}
}
}
let mut results: Vec<ConflictEntry> = map
.into_iter()
.filter(|(_, locs)| locs.len() >= 2)
.map(|(name, locs)| ConflictEntry {
name,
locations: locs
.into_iter()
.map(|(priority, dir)| ConflictLocation { dir, priority })
.collect(),
})
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
#[tauri::command]
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
let query_lower = query.to_lowercase();
let mut groups: Vec<ToolGroup> = Vec::new();
for dir in &paths {
let p = Path::new(dir);
if !p.is_dir() {
groups.push(ToolGroup {
dir: dir.clone(),
exists: false,
exes: vec![],
});
continue;
}
let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?;
let mut exes: Vec<String> = Vec::new();
for entry in entries.flatten() {
let fname = entry.file_name();
let name = fname.to_string_lossy();
if let Some(ext) = Path::new(name.as_ref()).extension() {
let ext_lower = ext.to_ascii_lowercase();
if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) {
if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) {
exes.push(name.to_string());
}
}
}
}
exes.sort();
groups.push(ToolGroup {
dir: dir.clone(),
exists: true,
exes,
});
}
Ok(groups)
}
+2 -2
View File
@@ -53,8 +53,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();
} }
+7
View File
@@ -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");
+2 -2
View File
@@ -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": "4.2.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 v4.2",
"width": 900, "width": 900,
"height": 700, "height": 700,
"minWidth": 800, "minWidth": 800,
+216
View File
@@ -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);
}
+240
View File
@@ -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>
);
}
+9 -1
View File
@@ -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>
); );
} }
+1 -1
View File
@@ -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)',
+8
View File
@@ -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>
+17 -1
View File
@@ -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];
+6
View File
@@ -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]);
+28
View File
@@ -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"
} }
+28
View File
@@ -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"
} }
+34 -5
View File
@@ -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 });
} }
}, },
+2
View File
@@ -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);
} }
/* 滚动条样式 */ /* 滚动条样式 */
+1 -1
View File
@@ -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