mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-30 02:25:55 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7869886670 | |||
| 49ef9c0cff | |||
| 344011a02c | |||
| 3aed03f599 | |||
| d7bc752b84 | |||
| c1975e836c | |||
| be04b7d0da | |||
| 2b372cbf89 | |||
| 45e2a4e584 | |||
| ff343185c9 | |||
| 6d711d0f8e | |||
| d6e535aa98 | |||
| e646a84291 | |||
| 611a36fb98 | |||
| ab2d0da20c | |||
| 914b25f236 | |||
| 32287c0e4b | |||
| 71b98e308a | |||
| fcd4796fee | |||
| 8ff02fd88b | |||
| 39a95cc50d | |||
| 44fdc2eec6 | |||
| 6dc32dca93 | |||
| a2b66d087f | |||
| 8c1655d25c |
@@ -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: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
<!-- 你会在什么场景下需要这个功能? -->
|
||||||
|
|
||||||
|
## 建议方案
|
||||||
|
|
||||||
|
<!-- 你期望的功能是什么样的? -->
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: 前端检查 (TypeScript + Lint + Test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: TypeScript 类型检查
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Vitest 测试
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
rust:
|
||||||
|
name: Rust 检查 (Check + Clippy + Test)
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src-tauri
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cargo Check
|
||||||
|
run: cargo check
|
||||||
|
|
||||||
|
- name: Cargo Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Cargo Test
|
||||||
|
run: cargo test
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
name: 构建 NSIS 安装包并发布
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Tauri Build
|
||||||
|
run: npx tauri build
|
||||||
|
|
||||||
|
- name: 上传安装包到 Release
|
||||||
|
run: |
|
||||||
|
$installer = Get-ChildItem -Path "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
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -24,3 +24,5 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
e2e/debug-screenshot.png
|
||||||
|
test-results/
|
||||||
|
|||||||
+51
-12
@@ -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#开发)。
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 | 用户体验小瑕疵 |
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# v4.2 CI/CD 流水线 — 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 为 PathEditor 添加 GitHub Actions CI/CD:push 自动检查 + tag 自动构建发布
|
||||||
|
|
||||||
|
**Architecture:** 两个 workflow 文件。前端 job 跑 ubuntu(快),Rust job 跑 windows(winreg 依赖)。tag 推送触发 NSIS 构建上传。
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions, Windows runner, MinGW (MSYS2), Tauri CLI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 创建 CI workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建目录并写入 ci.yml**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .github/workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/ci.yml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
name: 前端检查 (TypeScript + Lint + Test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: TypeScript 类型检查
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Vitest 测试
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
rust:
|
||||||
|
name: Rust 检查 (Check + Clippy + Test)
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src-tauri
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 安装 GNU 工具链
|
||||||
|
run: |
|
||||||
|
rustup toolchain install stable-x86_64-pc-windows-gnu
|
||||||
|
rustup override set stable-x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
- name: 添加 MinGW 到 PATH
|
||||||
|
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||||
|
|
||||||
|
- name: Cargo Check
|
||||||
|
run: cargo check
|
||||||
|
|
||||||
|
- name: Cargo Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Cargo Test
|
||||||
|
run: cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 本地验证 YAML 语法**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 可以用 Python 验证 YAML 语法(可选)
|
||||||
|
python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" 2>/dev/null || echo "跳过(无需本地验证,push 后 GitHub 自行检查)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/ci.yml
|
||||||
|
git commit -m "ci: 添加 CI workflow — push 自动检查 TypeScript + Rust
|
||||||
|
|
||||||
|
前端: tsc --noEmit + ESLint + Vitest (ubuntu)
|
||||||
|
Rust: cargo check + clippy + test (windows + GNU toolchain)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 创建 Release workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/release.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写入 release.yml**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/release.yml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
name: 构建 NSIS 安装包并发布
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: 安装 GNU 工具链
|
||||||
|
run: |
|
||||||
|
rustup toolchain install stable-x86_64-pc-windows-gnu
|
||||||
|
rustup override set stable-x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
- name: 添加 MinGW 到 PATH
|
||||||
|
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||||
|
|
||||||
|
- name: Tauri Build
|
||||||
|
run: npx tauri build
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: 上传安装包到 Release
|
||||||
|
run: |
|
||||||
|
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
|
||||||
|
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/release.yml
|
||||||
|
git commit -m "ci: 添加 Release workflow — tag 推送自动构建 NSIS 安装包并发布
|
||||||
|
|
||||||
|
tag v* 触发 Tauri build,生成 NSIS 安装包后上传到 GitHub Release
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 推送并验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 推送到 GitHub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin v4.2
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 查看 GitHub Actions**
|
||||||
|
|
||||||
|
打开 `https://github.com/LHY0125/PathEditor/actions`,确认 CI workflow 已触发并等待结果。
|
||||||
|
|
||||||
|
两个 job 应该都绿:
|
||||||
|
- `前端检查` — tsc + lint + vitest 通过
|
||||||
|
- `Rust 检查` — check + clippy + test 通过
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证 Release workflow(可选)**
|
||||||
|
|
||||||
|
推送一个测试 tag:
|
||||||
|
```bash
|
||||||
|
git tag -a v4.2.0-beta -m "测试 CI release"
|
||||||
|
git push origin v4.2.0-beta
|
||||||
|
```
|
||||||
|
|
||||||
|
确认 `https://github.com/LHY0125/PathEditor/releases` 出现构建产物。测试完成后删除 tag:
|
||||||
|
```bash
|
||||||
|
git push origin --delete v4.2.0-beta
|
||||||
|
git tag -d v4.2.0-beta
|
||||||
|
gh release delete v4.2.0-beta --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **TAURI_SIGNING_PRIVATE_KEY**: 如果项目签名配置了 Tauri updater 密钥,需要在 GitHub Settings → Secrets 中添加这两个 secret。如果当前没有配置 updater 签名,`tauri build` 会跳过签名步骤正常构建,但 CI 那一步会报找不到环境变量的警告。可以先不加这两个 secret,构建如果失败再加。
|
||||||
|
|
||||||
|
2. **首次运行**: GitHub Actions 在第一次 push `.github/workflows/` 后才会出现,之前需要等待。
|
||||||
|
|
||||||
|
3. **MinGW 路径**: `C:\msys64\mingw64\bin` 是 `windows-latest` runner 的固定路径。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
# v4.2 CI/CD 流水线 — 设计文档
|
||||||
|
|
||||||
|
**日期**: 2026-05-27
|
||||||
|
**分支**: v4.2
|
||||||
|
**状态**: 已确认
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为 PathEditor 添加 GitHub Actions CI/CD,实现 push 自动检查 + tag 自动构建发布。
|
||||||
|
|
||||||
|
## 触发策略
|
||||||
|
|
||||||
|
| 触发条件 | 做什么 |
|
||||||
|
|----------|--------|
|
||||||
|
| push 任意分支(不含 tag) | 前端类型检查 + lint + 测试,Rust check + clippy + test |
|
||||||
|
| 推送 tag `v*` | Tauri 构建 NSIS 安装包,上传到 GitHub Release |
|
||||||
|
|
||||||
|
## Workflow 1: CI
|
||||||
|
|
||||||
|
**文件**: `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
两个并行 job:
|
||||||
|
|
||||||
|
**frontend (ubuntu-latest)**:
|
||||||
|
- 用 ubuntu 而非 windows,更快且不依赖系统 API
|
||||||
|
- 步骤:checkout → setup-node → npm ci → tsc --noEmit → npm run lint → npm test
|
||||||
|
|
||||||
|
**rust (windows-latest)**:
|
||||||
|
- 必须用 windows(`winreg` crate 依赖 Windows API)
|
||||||
|
- 安装 GNU 工具链并 override,添加 MinGW bin 到 PATH
|
||||||
|
- 步骤:checkout → rustup toolchain install → override → PATH → cargo check → cargo clippy -- -D warnings → cargo test
|
||||||
|
|
||||||
|
## Workflow 2: Release
|
||||||
|
|
||||||
|
**文件**: `.github/workflows/release.yml`
|
||||||
|
|
||||||
|
单一 job `build-and-release` (windows-latest):
|
||||||
|
- checkout → setup-node → npm ci → rustup + MinGW → npx tauri build → gh release upload
|
||||||
|
|
||||||
|
构建产物:NSIS 安装包(`.exe`),上传到对应 tag 的 GitHub Release。
|
||||||
|
|
||||||
|
## MinGW 处理
|
||||||
|
|
||||||
|
- GitHub Actions `windows-latest` 自带 MSYS2,MinGW 位于 `C:\msys64\mingw64\bin`
|
||||||
|
- `cargo test` 运行时需要 `libmcfgthread-2.dll`,将此路径加入 `PATH` 即可
|
||||||
|
|
||||||
|
## 范围限制
|
||||||
|
|
||||||
|
- 不做跨平台构建(项目仅面向 Windows)
|
||||||
|
- 不做覆盖率门槛
|
||||||
|
- Release 不重复跑 CI(tag 推送说明已通过 push 检查)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# v4.3 路径启用/禁用 + E2E 测试 — 设计文档
|
||||||
|
|
||||||
|
**日期**: 2026-05-27
|
||||||
|
**分支**: v4.2(v4.3 后续创建)
|
||||||
|
**状态**: 已确认
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
两项独立改进:
|
||||||
|
1. 路径启用/禁用(软开关):`string[]` → `PathEntry[]`,禁用状态存 JSON 文件
|
||||||
|
2. E2E 测试:Playwright + Mock IPC,覆盖 4 条关键流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: 路径启用/禁用
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/core/path-entry.ts (新增)
|
||||||
|
export interface PathEntry {
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
全栈从 `string[]` 迁移到 `PathEntry[]`。注册表读写时做转换。
|
||||||
|
|
||||||
|
### 禁用状态持久化
|
||||||
|
|
||||||
|
文件:`%APPDATA%/PathEditor/disabled.json`
|
||||||
|
格式:`{ "system": ["path1"], "user": ["path2"] }`
|
||||||
|
|
||||||
|
加载流程:注册表读取 → `PathEntry[]`(全部 enabled:true)→ 读取 disabled.json → 匹配到的标记 enabled:false
|
||||||
|
|
||||||
|
保存流程:只将 enabled:true 的 path join 后写入注册表
|
||||||
|
|
||||||
|
切换流程:复选框点击 → 更新内存状态 → 立即调用 `save_disabled_state` 写入 JSON
|
||||||
|
|
||||||
|
### 新增 Rust 命令
|
||||||
|
|
||||||
|
- `save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String>`
|
||||||
|
- `load_disabled_state() -> Result<Vec<String>, Vec<String>, String>` 返回 `(system_disabled, user_disabled)`
|
||||||
|
|
||||||
|
### Undo 适配
|
||||||
|
|
||||||
|
- `OpRecord.oldPaths` / `newPaths` 从 `string[]` 改为 `PathEntry[]`
|
||||||
|
- 新增 `OperationType.TOGGLE = 8`:单条路径切换启用/禁用,undo 翻转回去
|
||||||
|
- 8 种已有操作类型的 switch case 适配 `PathEntry`
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- 路径列表每行前加复选框,`#` 序号列与复选框合并
|
||||||
|
- 禁用行:灰色文字 + 删除线(`text-decoration: line-through`)
|
||||||
|
- 工具栏不新增按钮(复选框独立操作)
|
||||||
|
|
||||||
|
### 状态层
|
||||||
|
|
||||||
|
- `app-store.ts` 新增 `togglePath(index, target)` 方法
|
||||||
|
- `loadPaths` 加载时合并 disabled 状态
|
||||||
|
- `savePaths` 保存时过滤 disabled 路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: E2E 测试
|
||||||
|
|
||||||
|
### 技术选型
|
||||||
|
|
||||||
|
`@playwright/test`,独立于 Vitest。Mock Tauri IPC 通过 `page.addInitScript()` 注入。
|
||||||
|
|
||||||
|
### Mock IPC
|
||||||
|
|
||||||
|
页面加载前注入 `window.__TAURI_INTERNALS__.invoke` 的 mock 实现,按命令名返回假数据。
|
||||||
|
|
||||||
|
### 4 条测试场景
|
||||||
|
|
||||||
|
| 场景 | 步骤 |
|
||||||
|
|------|------|
|
||||||
|
| 启动加载 | 访问页面 → 系统 PATH 显示 2 条 → 用户 PATH 显示 1 条 |
|
||||||
|
| CRUD + 撤销 | 添加路径 → 出现在列表 → Ctrl+Z 撤销 → 路径消失 → Ctrl+Y 重做 → 路径恢复 |
|
||||||
|
| 禁用 + 保存 | 点击复选框禁用 → 路径灰显+删除线 → 点保存 → 验证 save IPC 只传了 enabled 路径 |
|
||||||
|
| 搜索 + 清理 | 输入搜索词 → 列表过滤 → 清空搜索 → 点清理 → 无效路径红色 |
|
||||||
|
|
||||||
|
### 运行方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
需要先启动 Vite 开发服务器:`npm run dev`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function createIpcMock() {
|
||||||
|
return `
|
||||||
|
window.__TAURI_INTERNALS__ = {
|
||||||
|
invoke: async (cmd, args) => {
|
||||||
|
switch (cmd) {
|
||||||
|
case 'check_admin': return true;
|
||||||
|
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
|
||||||
|
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
|
||||||
|
case 'load_disabled_state': return [[], []];
|
||||||
|
case 'save_system_paths': return undefined;
|
||||||
|
case 'save_user_paths': return undefined;
|
||||||
|
case 'save_disabled_state': return undefined;
|
||||||
|
case 'backup_registry': return 'C:\\\\backup\\\\path.txt';
|
||||||
|
case 'broadcast_env_change': return undefined;
|
||||||
|
case 'validate_path': return true;
|
||||||
|
case 'expand_env_vars': return 'C:\\\\Expanded';
|
||||||
|
case 'read_text_file': return '';
|
||||||
|
case 'get_appdata_dir': return 'C:\\\\appdata';
|
||||||
|
default: throw new Error('Unexpected invoke: ' + cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 10000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createIpcMock } from '../mocks/ipc';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(createIpcMock());
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('添加路径后可撤销和重做', async ({ page }) => {
|
||||||
|
// 点击"新建"按钮
|
||||||
|
await page.click('text=新建');
|
||||||
|
// 填入路径(对话框内自动聚焦的 input)
|
||||||
|
await page.locator('.fixed.inset-0 input[type="text"]').fill('C:\\\\NewPath');
|
||||||
|
// 对话框确认按钮是"确认"
|
||||||
|
await page.click('text=确认');
|
||||||
|
// 路径应出现在列表中
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||||||
|
|
||||||
|
// Ctrl+Z 撤销
|
||||||
|
await page.keyboard.press('Control+z');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('text=C:\\\\NewPath')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Ctrl+Y 重做
|
||||||
|
await page.keyboard.press('Control+y');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__TAURI_INTERNALS__ = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
invoke: async (cmd, args) => {
|
||||||
|
switch (cmd) {
|
||||||
|
case 'check_admin': return true;
|
||||||
|
case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
|
||||||
|
case 'load_user_paths': return [];
|
||||||
|
case 'load_disabled_state': return { system: [], user: [] };
|
||||||
|
case 'save_system_paths': return undefined;
|
||||||
|
case 'save_user_paths': return undefined;
|
||||||
|
case 'save_disabled_state': return undefined;
|
||||||
|
case 'backup_registry': return '';
|
||||||
|
case 'broadcast_env_change': return undefined;
|
||||||
|
case 'validate_path': return false;
|
||||||
|
case 'expand_env_vars': return '';
|
||||||
|
case 'read_text_file': return '';
|
||||||
|
case 'get_appdata_dir': return '';
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('搜索过滤后清理无效路径', async ({ page }) => {
|
||||||
|
// 初始 3 条路径
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(3);
|
||||||
|
|
||||||
|
// 搜索 "Windows"
|
||||||
|
const searchInput = page.locator('input[placeholder]');
|
||||||
|
await searchInput.fill('Windows');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
|
await searchInput.fill('');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(3);
|
||||||
|
|
||||||
|
// 点击"一键清理"按钮
|
||||||
|
await page.click('text=一键清理');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
// is_valid_path_format 只校验格式,不检查存在性
|
||||||
|
// "invalid_path" 格式无效被移除,C:\Windows 和 C:\Temp 格式有效保留
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createIpcMock } from '../mocks/ipc';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(createIpcMock());
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('启动后加载系统 PATH 和用户 PATH', async ({ page }) => {
|
||||||
|
// 系统 tab 默认激活,显示 2 条路径
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||||||
|
|
||||||
|
// 切换到用户 tab
|
||||||
|
await page.click('text=用户变量');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createIpcMock } from '../mocks/ipc';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(createIpcMock());
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('禁用路径后灰显并保存', async ({ page }) => {
|
||||||
|
// 点击第一个路径的 checkbox 将其禁用
|
||||||
|
const checkbox = page.locator('table tbody tr').first().locator('input[type="checkbox"]');
|
||||||
|
await checkbox.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// 路径文本应有删除线样式(第 3 列是路径列,nth(2) 即 0-indexed 第 3 个 td)
|
||||||
|
const row = page.locator('table tbody tr').first();
|
||||||
|
await expect(row.locator('td').nth(2)).toHaveCSS('text-decoration-line', 'line-through');
|
||||||
|
|
||||||
|
// 点击"确定"保存
|
||||||
|
await page.click('text=确定');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
// 状态栏应显示"保存成功"
|
||||||
|
await expect(page.locator('text=保存成功')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'src-tauri'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+68
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "v4.0",
|
"name": "patheditor",
|
||||||
"version": "0.0.0",
|
"version": "4.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "v4.0",
|
"name": "patheditor",
|
||||||
"version": "0.0.0",
|
"version": "4.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -582,6 +583,22 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||||
@@ -3050,6 +3067,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
+4
-2
@@ -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",
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test --config e2e/playwright.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn disabled_file_path() -> PathBuf {
|
||||||
|
dirs::data_dir()
|
||||||
|
.or_else(dirs::home_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("PathEditor")
|
||||||
|
.join("disabled.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct DisabledState {
|
||||||
|
#[serde(default)]
|
||||||
|
system: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
user: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
|
||||||
|
let state = DisabledState { system, user };
|
||||||
|
let path = disabled_file_path();
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("无法创建配置目录: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&state)
|
||||||
|
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&path, &json)
|
||||||
|
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("已保存禁用状态到: {}", path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
|
||||||
|
let path = disabled_file_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok((vec![], vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
|
||||||
|
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Ok((vec![], vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: DisabledState = serde_json::from_str(&content)
|
||||||
|
.map_err(|e| format!("JSON 解析失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok((state.system, state.user))
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod registry;
|
|
||||||
pub mod system;
|
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod disabled;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod scanner;
|
||||||
|
pub mod system;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub fn run() {
|
|||||||
commands::backup::backup_registry,
|
commands::backup::backup_registry,
|
||||||
commands::backup::get_appdata_dir,
|
commands::backup::get_appdata_dir,
|
||||||
commands::fs::read_text_file,
|
commands::fs::read_text_file,
|
||||||
|
commands::disabled::save_disabled_state,
|
||||||
|
commands::disabled::load_disabled_state,
|
||||||
|
commands::scanner::scan_conflicts,
|
||||||
|
commands::scanner::scan_tools,
|
||||||
])
|
])
|
||||||
.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": "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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { 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 +34,11 @@ 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 actions = useAppActions(activeTab, {
|
const actions = useAppActions(activeTab, {
|
||||||
editDialog, newDialog, helpOpen, importDialog,
|
editDialog, newDialog, helpOpen, importDialog,
|
||||||
setEditDialog, setNewDialog, setHelpOpen, setImportDialog,
|
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabConfig: { id: TabId; label: string }[] = [
|
const tabConfig: { id: TabId; label: string }[] = [
|
||||||
@@ -84,6 +86,7 @@ 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');
|
||||||
}}
|
}}
|
||||||
|
onAnalyze={() => setAnalyzeOpen(true)}
|
||||||
onDarkMode={() => useThemeStore.getState().toggle()}
|
onDarkMode={() => useThemeStore.getState().toggle()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +115,7 @@ 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)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
|
|
||||||
export function MergePreview() {
|
export function MergePreview() {
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
@@ -9,13 +10,27 @@ export function MergePreview() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const allPaths = useMemo(() => {
|
const allPaths = useMemo(() => {
|
||||||
const result: { path: string; source: string; index: number }[] = [];
|
const seen = new Set<string>();
|
||||||
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
|
const merged: (PathEntry & { source: string; displayIndex: number })[] = [];
|
||||||
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
|
|
||||||
|
|
||||||
if (!searchQuery) return result;
|
for (const entry of sysPaths) {
|
||||||
|
const lower = entry.path.toLowerCase();
|
||||||
|
if (!seen.has(lower)) {
|
||||||
|
seen.add(lower);
|
||||||
|
merged.push({ ...entry, source: t('merge.system'), displayIndex: merged.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of userPaths) {
|
||||||
|
const lower = entry.path.toLowerCase();
|
||||||
|
if (!seen.has(lower)) {
|
||||||
|
seen.add(lower);
|
||||||
|
merged.push({ ...entry, source: t('merge.user'), displayIndex: merged.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchQuery) return merged;
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return result.filter((r) => r.path.toLowerCase().includes(q));
|
return merged.filter((r) => r.path.toLowerCase().includes(q));
|
||||||
}, [sysPaths, userPaths, searchQuery, t]);
|
}, [sysPaths, userPaths, searchQuery, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,9 +47,14 @@ export function MergePreview() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allPaths.map(({ path, source, index }, rowIdx) => (
|
{allPaths.map(({ path, enabled, source, displayIndex }, rowIdx) => {
|
||||||
|
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
|
||||||
|
const textDecoration = enabled ? 'none' : 'line-through';
|
||||||
|
const opacity = enabled ? 1 : 0.6;
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`${source}-${index}`}
|
key={`${source}-${displayIndex}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||||
@@ -42,10 +62,16 @@ export function MergePreview() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
|
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
|
||||||
<td className="px-2 py-0.5 text-sm">{path}</td>
|
<td
|
||||||
|
className="px-2 py-0.5 text-sm"
|
||||||
|
style={{ color: textColor, textDecoration, opacity }}
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
|
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { TargetType } from '@/core/undo-redo';
|
||||||
|
|
||||||
interface PathTableProps {
|
interface PathTableProps {
|
||||||
tabId: 'system' | 'user';
|
tabId: 'system' | 'user';
|
||||||
@@ -9,6 +10,7 @@ interface PathTableProps {
|
|||||||
interface PathRow {
|
interface PathRow {
|
||||||
path: string;
|
path: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationState = 'valid' | 'invalid' | 'unknown';
|
type ValidationState = 'valid' | 'invalid' | 'unknown';
|
||||||
@@ -35,12 +37,12 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
|
|
||||||
// 过滤搜索
|
// 过滤搜索
|
||||||
const filtered = useMemo<PathRow[]>(() => {
|
const filtered = useMemo<PathRow[]>(() => {
|
||||||
if (!searchQuery) return paths.map((p, i) => ({ path: p, index: i }));
|
if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled }));
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const result: PathRow[] = [];
|
const result: PathRow[] = [];
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
const p = paths[i];
|
const p = paths[i];
|
||||||
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
|
if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [paths, searchQuery]);
|
}, [paths, searchQuery]);
|
||||||
@@ -48,18 +50,18 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
// 异步验证未缓存的路径
|
// 异步验证未缓存的路径
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const toValidate = paths.filter((p) => !validatedRef.current.has(p));
|
const toValidate = paths.filter((p) => !validatedRef.current.has(p.path));
|
||||||
if (toValidate.length === 0) return;
|
if (toValidate.length === 0) return;
|
||||||
|
|
||||||
const batch = toValidate.slice(0, 20);
|
const batch = toValidate.slice(0, 20);
|
||||||
Promise.all(
|
Promise.all(
|
||||||
batch.map(async (p): Promise<[string, ValidationState]> => {
|
batch.map(async (p): Promise<[string, ValidationState]> => {
|
||||||
try {
|
try {
|
||||||
if (p.includes('%')) return [p, 'valid'];
|
if (p.path.includes('%')) return [p.path, 'valid'];
|
||||||
const valid: boolean = await invoke('validate_path', { path: p });
|
const valid: boolean = await invoke('validate_path', { path: p.path });
|
||||||
return [p, valid ? 'valid' : 'invalid'];
|
return [p.path, valid ? 'valid' : 'invalid'];
|
||||||
} catch {
|
} catch {
|
||||||
return [p, 'unknown'];
|
return [p.path, 'unknown'];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
@@ -79,7 +81,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const toExpand = paths.filter(
|
const toExpand = paths.filter(
|
||||||
(p) => p.includes('%') && !expandedRef.current.has(p),
|
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
|
||||||
);
|
);
|
||||||
if (toExpand.length === 0) return;
|
if (toExpand.length === 0) return;
|
||||||
|
|
||||||
@@ -87,10 +89,10 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
batch.map(async (p): Promise<[string, string]> => {
|
batch.map(async (p): Promise<[string, string]> => {
|
||||||
try {
|
try {
|
||||||
const expanded: string = await invoke('expand_env_vars', { path: p });
|
const expanded: string = await invoke('expand_env_vars', { path: p.path });
|
||||||
return [p, expanded !== p ? expanded : ''];
|
return [p.path, expanded !== p.path ? expanded : ''];
|
||||||
} catch {
|
} catch {
|
||||||
return [p, ''];
|
return [p.path, ''];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
@@ -141,7 +143,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('path-dblclick', {
|
new CustomEvent('path-dblclick', {
|
||||||
detail: { index: realIndex, path: paths[realIndex] },
|
detail: { index: realIndex, path: paths[realIndex].path },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -157,11 +159,12 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
<th className="w-8 px-2 py-1">#</th>
|
<th className="w-8 px-2 py-1">#</th>
|
||||||
|
<th className="w-6 px-1 py-1"></th>
|
||||||
<th className="px-2 py-1">路径</th>
|
<th className="px-2 py-1">路径</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(({ path, index }, rowIdx) => {
|
{filtered.map(({ path, index, enabled }, rowIdx) => {
|
||||||
const v = validations[rowIdx];
|
const v = validations[rowIdx];
|
||||||
const isSelected = selectedIndices.includes(index);
|
const isSelected = selectedIndices.includes(index);
|
||||||
let textColor = 'var(--app-fg)';
|
let textColor = 'var(--app-fg)';
|
||||||
@@ -169,6 +172,14 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
else if (v.isDuplicate) textColor = '#fd7e14';
|
else if (v.isDuplicate) textColor = '#fd7e14';
|
||||||
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
|
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
|
||||||
|
|
||||||
|
let textDecoration = 'none';
|
||||||
|
let opacity = 1;
|
||||||
|
if (!enabled) {
|
||||||
|
textColor = '#6b7280';
|
||||||
|
textDecoration = 'line-through';
|
||||||
|
opacity = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
@@ -186,9 +197,20 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="w-6 px-1 py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={() => {
|
||||||
|
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
|
||||||
|
useAppStore.getState().togglePath(index, target);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
||||||
style={{ color: textColor }}
|
style={{ color: textColor, textDecoration, opacity }}
|
||||||
title={expandedCache.get(path) || undefined}
|
title={expandedCache.get(path) || undefined}
|
||||||
>
|
>
|
||||||
{path}
|
{path}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ToolBarProps {
|
|||||||
onHelp: () => void;
|
onHelp: () => void;
|
||||||
onLanguage: () => void;
|
onLanguage: () => void;
|
||||||
onDarkMode: () => void;
|
onDarkMode: () => void;
|
||||||
|
onAnalyze: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolBar(props: ToolBarProps) {
|
export function ToolBar(props: ToolBarProps) {
|
||||||
@@ -66,6 +67,9 @@ 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.onDarkMode}>
|
<button className={btnClass} style={btnStyle} onClick={props.onDarkMode}>
|
||||||
{t('button.darkMode')}
|
{t('button.darkMode')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+23
-21
@@ -2,11 +2,13 @@
|
|||||||
* 导入导出模块 — 对应 C 版 import_export.c
|
* 导入导出模块 — 对应 C 版 import_export.c
|
||||||
* 支持 JSON、CSV、TXT 三种格式
|
* 支持 JSON、CSV、TXT 三种格式
|
||||||
*/
|
*/
|
||||||
|
import type { PathEntry } from './path-entry';
|
||||||
|
|
||||||
export type ExportFormat = 'json' | 'csv' | 'txt';
|
export type ExportFormat = 'json' | 'csv' | 'txt';
|
||||||
|
|
||||||
export interface ExportData {
|
export interface ExportData {
|
||||||
system: string[];
|
system: PathEntry[];
|
||||||
user: string[];
|
user: PathEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据文件扩展名检测格式 */
|
/** 根据文件扩展名检测格式 */
|
||||||
@@ -24,8 +26,8 @@ export function exportToJson(data: ExportData): string {
|
|||||||
version: '1.0',
|
version: '1.0',
|
||||||
type: 'PathEditor',
|
type: 'PathEditor',
|
||||||
exported: new Date().toISOString(),
|
exported: new Date().toISOString(),
|
||||||
system: data.system,
|
system: data.system.map(e => e.path),
|
||||||
user: data.user,
|
user: data.user.map(e => e.path),
|
||||||
};
|
};
|
||||||
return JSON.stringify(obj, null, 2);
|
return JSON.stringify(obj, null, 2);
|
||||||
}
|
}
|
||||||
@@ -37,11 +39,11 @@ export function exportToCsv(data: ExportData): string {
|
|||||||
// UTF-8 BOM
|
// UTF-8 BOM
|
||||||
lines.push('type,path');
|
lines.push('type,path');
|
||||||
|
|
||||||
for (const path of data.system) {
|
for (const entry of data.system) {
|
||||||
lines.push(`system,${escapeCsvField(path)}`);
|
lines.push(`system,${escapeCsvField(entry.path)}`);
|
||||||
}
|
}
|
||||||
for (const path of data.user) {
|
for (const entry of data.user) {
|
||||||
lines.push(`user,${escapeCsvField(path)}`);
|
lines.push(`user,${escapeCsvField(entry.path)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n') + '\n';
|
return lines.join('\n') + '\n';
|
||||||
@@ -57,8 +59,8 @@ function escapeCsvField(field: string): string {
|
|||||||
// ── CSV 导入 ──
|
// ── CSV 导入 ──
|
||||||
|
|
||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
system: string[];
|
system: PathEntry[];
|
||||||
user: string[];
|
user: PathEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importFromCsv(content: string): ImportResult {
|
export function importFromCsv(content: string): ImportResult {
|
||||||
@@ -91,9 +93,9 @@ export function importFromCsv(content: string): ImportResult {
|
|||||||
if (path.length === 0) continue;
|
if (path.length === 0) continue;
|
||||||
|
|
||||||
if (type === 'system') {
|
if (type === 'system') {
|
||||||
result.system.push(path);
|
result.system.push({ path, enabled: true });
|
||||||
} else if (type === 'user') {
|
} else if (type === 'user') {
|
||||||
result.user.push(path);
|
result.user.push({ path, enabled: true });
|
||||||
}
|
}
|
||||||
// 未知类型忽略
|
// 未知类型忽略
|
||||||
}
|
}
|
||||||
@@ -157,14 +159,14 @@ export function importFromJson(content: string): ImportResult {
|
|||||||
if (typeof obj !== 'object' || obj === null) return result;
|
if (typeof obj !== 'object' || obj === null) return result;
|
||||||
|
|
||||||
if (Array.isArray(obj.system)) {
|
if (Array.isArray(obj.system)) {
|
||||||
result.system = obj.system.filter(
|
result.system = obj.system
|
||||||
(p: unknown) => typeof p === 'string' && p.trim().length > 0,
|
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||||
);
|
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.user)) {
|
if (Array.isArray(obj.user)) {
|
||||||
result.user = obj.user.filter(
|
result.user = obj.user
|
||||||
(p: unknown) => typeof p === 'string' && p.trim().length > 0,
|
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||||
);
|
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -172,8 +174,8 @@ export function importFromJson(content: string): ImportResult {
|
|||||||
|
|
||||||
// ── TXT 导入 ──
|
// ── TXT 导入 ──
|
||||||
|
|
||||||
export function importFromTxt(content: string): string[] {
|
export function importFromTxt(content: string): PathEntry[] {
|
||||||
const paths: string[] = [];
|
const paths: PathEntry[] = [];
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
@@ -184,7 +186,7 @@ export function importFromTxt(content: string): string[] {
|
|||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
paths.push(trimmed);
|
paths.push({ path: trimmed, enabled: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths;
|
return paths;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** PATH 路径条目 — 包含路径值和启用状态 */
|
||||||
|
export interface PathEntry {
|
||||||
|
path: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 路径管理器 — 不可变的 string[] 操作
|
* 路径管理器 — 不可变的 PathEntry[] 操作
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { PathEntry } from './path-entry';
|
||||||
|
|
||||||
export interface PathValidation {
|
export interface PathValidation {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
@@ -9,17 +11,17 @@ export interface PathValidation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function analyzePaths(
|
export function analyzePaths(
|
||||||
paths: readonly string[],
|
paths: readonly PathEntry[],
|
||||||
validateFn: (path: string) => boolean,
|
validateFn: (path: string) => boolean,
|
||||||
): PathValidation[] {
|
): PathValidation[] {
|
||||||
const result: PathValidation[] = [];
|
const result: PathValidation[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const entry of paths) {
|
||||||
const lower = path.toLowerCase();
|
const lower = entry.path.toLowerCase();
|
||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
result.push({ isValid: validateFn(path), isDuplicate, isEnvVar: path.includes('%') });
|
result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -27,12 +29,12 @@ export function analyzePaths(
|
|||||||
|
|
||||||
/** 从数组中移除无效和重复路径,返回 [新数组, 被移除的路径] */
|
/** 从数组中移除无效和重复路径,返回 [新数组, 被移除的路径] */
|
||||||
export function pathClean(
|
export function pathClean(
|
||||||
paths: readonly string[],
|
paths: readonly PathEntry[],
|
||||||
validateFn: (path: string) => boolean,
|
validateFn: (path: string) => boolean,
|
||||||
): [string[], string[]] {
|
): [PathEntry[], PathEntry[]] {
|
||||||
const analysis = analyzePaths(paths, validateFn);
|
const analysis = analyzePaths(paths, validateFn);
|
||||||
const kept: string[] = [];
|
const kept: PathEntry[] = [];
|
||||||
const removed: string[] = [];
|
const removed: PathEntry[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
const a = analysis[i];
|
const a = analysis[i];
|
||||||
|
|||||||
+30
-6
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 撤销/重做管理器 — 纯逻辑,操作不可变 string[]
|
* 撤销/重做管理器 — 纯逻辑,操作不可变 PathEntry[]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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,
|
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];
|
||||||
|
|
||||||
@@ -15,10 +17,14 @@ export interface OpRecord {
|
|||||||
target: TargetType;
|
target: TargetType;
|
||||||
index: number;
|
index: number;
|
||||||
count: number;
|
count: number;
|
||||||
oldPaths: string[];
|
oldPaths: PathEntry[];
|
||||||
newPaths: string[];
|
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;
|
||||||
@@ -41,7 +47,7 @@ export class UndoRedoManager {
|
|||||||
this.current = this.records.length - 1;
|
this.current = this.records.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
undo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
|
||||||
if (this.current < 0) return null;
|
if (this.current < 0) return null;
|
||||||
|
|
||||||
const rec = this.records[this.current];
|
const rec = this.records[this.current];
|
||||||
@@ -83,12 +89,21 @@ export class UndoRedoManager {
|
|||||||
case OperationType.CLEAR:
|
case OperationType.CLEAR:
|
||||||
target.push(...rec.oldPaths);
|
target.push(...rec.oldPaths);
|
||||||
break;
|
break;
|
||||||
|
case OperationType.TOGGLE:
|
||||||
|
target[rec.index] = rec.oldPaths[0];
|
||||||
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
redo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
redo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
|
||||||
if (this.current >= this.records.length - 1) return null;
|
if (this.current >= this.records.length - 1) return null;
|
||||||
|
|
||||||
this.current++;
|
this.current++;
|
||||||
@@ -130,6 +145,15 @@ export class UndoRedoManager {
|
|||||||
case OperationType.CLEAR:
|
case OperationType.CLEAR:
|
||||||
target.length = 0;
|
target.length = 0;
|
||||||
break;
|
break;
|
||||||
|
case OperationType.TOGGLE:
|
||||||
|
target[rec.index] = rec.newPaths[0];
|
||||||
|
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];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TargetType } from '@/core/undo-redo';
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
import { is_valid_path_format } from '@/core/validation';
|
import { is_valid_path_format } from '@/core/validation';
|
||||||
import { useKeyboard } from './use-keyboard';
|
import { useKeyboard } from './use-keyboard';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
@@ -13,11 +14,12 @@ export interface DialogState {
|
|||||||
editDialog: { open: boolean; index: number; value: string; target: TargetType };
|
editDialog: { open: boolean; index: number; value: string; target: TargetType };
|
||||||
newDialog: boolean;
|
newDialog: boolean;
|
||||||
helpOpen: boolean;
|
helpOpen: boolean;
|
||||||
importDialog: { open: boolean; system: string[]; user: string[] };
|
importDialog: { open: boolean; system: PathEntry[]; user: PathEntry[] };
|
||||||
setEditDialog: (v: DialogState['editDialog']) => void;
|
setEditDialog: (v: DialogState['editDialog']) => void;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||||
@@ -38,8 +40,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
const list = target === TargetType.SYSTEM
|
const list = target === TargetType.SYSTEM
|
||||||
? useAppStore.getState().sysPaths
|
? useAppStore.getState().sysPaths
|
||||||
: useAppStore.getState().userPaths;
|
: useAppStore.getState().userPaths;
|
||||||
const value = list[idx];
|
const entry = list[idx];
|
||||||
if (value) setEditDialog({ open: true, index: idx, value, target });
|
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
|
||||||
}, [activeTab, setEditDialog]);
|
}, [activeTab, setEditDialog]);
|
||||||
|
|
||||||
const handleBrowse = useCallback(async () => {
|
const handleBrowse = useCallback(async () => {
|
||||||
@@ -92,9 +94,9 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
if (result.system.length > 0 && result.user.length > 0) {
|
if (result.system.length > 0 && result.user.length > 0) {
|
||||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||||
} else if (result.system.length > 0) {
|
} else if (result.system.length > 0) {
|
||||||
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system);
|
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system.map(e => e.path));
|
||||||
} else if (result.user.length > 0) {
|
} else if (result.user.length > 0) {
|
||||||
useAppStore.getState().replacePaths(TargetType.USER, result.user);
|
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
|
||||||
}
|
}
|
||||||
}, [setImportDialog]);
|
}, [setImportDialog]);
|
||||||
|
|
||||||
@@ -159,8 +161,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 (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system);
|
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
|
||||||
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user);
|
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.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]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface KeyboardActions {
|
|||||||
export function useKeyboard(actions: KeyboardActions) {
|
export function useKeyboard(actions: KeyboardActions) {
|
||||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||||
const actionsRef = useRef(actions);
|
const actionsRef = useRef(actions);
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- React 官方推荐的 ref 同步模式,避免每次渲染重复注册事件监听器
|
||||||
actionsRef.current = actions;
|
actionsRef.current = actions;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"save": "OK",
|
"save": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
"analyze": "Analyze",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
@@ -38,6 +39,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 +71,18 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"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,7 @@
|
|||||||
"save": "确定",
|
"save": "确定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
|
"analyze": "分析",
|
||||||
"undo": "撤销",
|
"undo": "撤销",
|
||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"readonly": "只读模式 — 需要管理员权限才能编辑",
|
"readonly": "只读模式 — 需要管理员权限才能编辑",
|
||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"saved": "保存成功",
|
"saved": "保存成功",
|
||||||
|
"saved_without_backup": "保存成功(备份失败)",
|
||||||
"error": "操作失败",
|
"error": "操作失败",
|
||||||
"warning_backup": "备份创建失败,保存将继续但不生成备份",
|
"warning_backup": "备份创建失败,保存将继续但不生成备份",
|
||||||
"deleted": "已删除 {{count}} 个路径",
|
"deleted": "已删除 {{count}} 个路径",
|
||||||
@@ -69,6 +71,18 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"search": "搜索路径..."
|
"search": "搜索路径..."
|
||||||
},
|
},
|
||||||
|
"analyze": {
|
||||||
|
"title": "PATH 分析",
|
||||||
|
"conflicts": "冲突检测",
|
||||||
|
"tools": "工具清单",
|
||||||
|
"scanning": "正在扫描...",
|
||||||
|
"noConflicts": "未发现可执行文件冲突",
|
||||||
|
"noTools": "未找到匹配的可执行文件",
|
||||||
|
"priority": "优先执行",
|
||||||
|
"shadowed": "被遮蔽",
|
||||||
|
"searchPlaceholder": "搜索可执行文件名...",
|
||||||
|
"conflictCount": "发现 {{count}} 个文件冲突"
|
||||||
|
},
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-26
@@ -3,16 +3,17 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
||||||
import { pathClean } from '@/core/path-manager';
|
import { pathClean } from '@/core/path-manager';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
import appConfig from '@/config/default.json';
|
import appConfig from '@/config/default.json';
|
||||||
|
|
||||||
export type TabId = 'system' | 'user' | 'merged';
|
export type TabId = 'system' | 'user' | 'merged';
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
sysPaths: string[];
|
sysPaths: PathEntry[];
|
||||||
userPaths: string[];
|
userPaths: PathEntry[];
|
||||||
undoRedo: UndoRedoManager;
|
undoRedo: UndoRedoManager;
|
||||||
_savedSys: string[]; // 上次保存时的快照,用于 isModified 判断
|
_savedSys: PathEntry[]; // 上次保存时的快照,用于 isModified 判断
|
||||||
_savedUser: string[];
|
_savedUser: PathEntry[];
|
||||||
|
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -35,8 +36,11 @@ 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;
|
||||||
|
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
|
|
||||||
@@ -46,8 +50,8 @@ interface AppState {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
function arraysEqual(a: readonly PathEntry[], b: readonly PathEntry[]): boolean {
|
||||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
return a.length === b.length && a.every((v, i) => v.path === b[i].path && v.enabled === b[i].enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set, get) => {
|
export const useAppStore = create<AppState>((set, get) => {
|
||||||
@@ -80,10 +84,11 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
addPath: (path, target) => {
|
addPath: (path, 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;
|
||||||
const newList = [...list, path];
|
const entry: PathEntry = { path, enabled: true };
|
||||||
|
const newList = [...list, entry];
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||||||
oldPaths: [], newPaths: [path],
|
oldPaths: [], newPaths: [entry],
|
||||||
});
|
});
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
else set({ userPaths: newList });
|
else set({ userPaths: newList });
|
||||||
@@ -93,14 +98,15 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
editPath: (index, newPath, target) => {
|
editPath: (index, newPath, 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;
|
||||||
const oldPath = list[index];
|
const oldEntry = list[index];
|
||||||
if (oldPath === undefined) return;
|
if (!oldEntry) return;
|
||||||
|
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.EDIT, target, index, count: 1,
|
type: OperationType.EDIT, target, index, count: 1,
|
||||||
oldPaths: [oldPath], newPaths: [newPath],
|
oldPaths: [oldEntry], newPaths: [newEntry],
|
||||||
});
|
});
|
||||||
const newList = [...list];
|
const newList = [...list];
|
||||||
newList[index] = newPath;
|
newList[index] = newEntry;
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
else set({ userPaths: newList });
|
else set({ userPaths: newList });
|
||||||
markDirty();
|
markDirty();
|
||||||
@@ -171,21 +177,36 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
return removed.map(e => e.path);
|
||||||
},
|
},
|
||||||
|
|
||||||
replacePaths: (target, newPaths) => {
|
replacePaths: (target, newPaths) => {
|
||||||
if (newPaths.length === 0) return;
|
if (newPaths.length === 0) return;
|
||||||
const state = get();
|
const state = get();
|
||||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
|
const entries: PathEntry[] = newPaths.map(p => ({ path: p, enabled: true }));
|
||||||
|
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.IMPORT, target, index: 0, count: newPaths.length,
|
type: OperationType.IMPORT, target, index: 0, count: entries.length,
|
||||||
oldPaths: [...list], newPaths: [...newPaths],
|
oldPaths: [...list], newPaths: [...entries],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...newPaths], selectedIndices: [] });
|
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
||||||
else set({ userPaths: [...newPaths], selectedIndices: [] });
|
else set({ userPaths: [...entries], selectedIndices: [] });
|
||||||
|
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();
|
markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -204,6 +225,32 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
togglePath: (index, target) => {
|
||||||
|
const state = get();
|
||||||
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
|
const oldEntry = list[index];
|
||||||
|
if (!oldEntry) return;
|
||||||
|
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
||||||
|
|
||||||
|
state.undoRedo.push({
|
||||||
|
type: OperationType.TOGGLE, target, index, count: 1,
|
||||||
|
oldPaths: [oldEntry], newPaths: [newEntry],
|
||||||
|
});
|
||||||
|
|
||||||
|
const newList = [...list];
|
||||||
|
newList[index] = newEntry;
|
||||||
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
|
else set({ userPaths: newList });
|
||||||
|
markDirty();
|
||||||
|
|
||||||
|
// 即时保存禁用状态
|
||||||
|
const { sysPaths: sys, userPaths: usr } = get();
|
||||||
|
const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path);
|
||||||
|
const usrDisabled = usr.filter(e => !e.enabled).map(e => e.path);
|
||||||
|
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled })
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
undo: () => {
|
undo: () => {
|
||||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||||
const result = undoRedo.undo(sysPaths, userPaths);
|
const result = undoRedo.undo(sysPaths, userPaths);
|
||||||
@@ -213,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(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -225,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(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -235,9 +292,27 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
invoke<string[]>('load_system_paths'),
|
invoke<string[]>('load_system_paths'),
|
||||||
invoke<string[]>('load_user_paths'),
|
invoke<string[]>('load_user_paths'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 加载禁用状态(文件不存在时返回空)
|
||||||
|
let sysDisabled: string[] = [];
|
||||||
|
let usrDisabled: string[] = [];
|
||||||
|
try {
|
||||||
|
const result = await invoke<[string[], string[]]>('load_disabled_state');
|
||||||
|
sysDisabled = result[0];
|
||||||
|
usrDisabled = result[1];
|
||||||
|
} catch {
|
||||||
|
// 文件不存在或损坏,忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
const sysSet = new Set(sysDisabled);
|
||||||
|
const usrSet = new Set(usrDisabled);
|
||||||
|
|
||||||
|
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysSet.has(p) }));
|
||||||
|
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrSet.has(p) }));
|
||||||
|
|
||||||
set({
|
set({
|
||||||
sysPaths: sysArr, userPaths: userArr,
|
sysPaths: sysEntries, userPaths: usrEntries,
|
||||||
_savedSys: [...sysArr], _savedUser: [...userArr],
|
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
|
||||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
isLoading: false, isModified: false,
|
isLoading: false, isModified: false,
|
||||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||||||
@@ -252,7 +327,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
if (state.isSaving) return;
|
if (state.isSaving) return;
|
||||||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||||
|
|
||||||
const { sysPaths, userPaths } = state;
|
// 只保存 enabled 的路径到注册表
|
||||||
|
const sysPaths = state.sysPaths.filter(e => e.enabled).map(e => e.path);
|
||||||
|
const userPaths = state.userPaths.filter(e => e.enabled).map(e => e.path);
|
||||||
const sysJoined = sysPaths.join(';');
|
const sysJoined = sysPaths.join(';');
|
||||||
const userJoined = userPaths.join(';');
|
const userJoined = userPaths.join(';');
|
||||||
|
|
||||||
@@ -262,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 }),
|
||||||
@@ -275,12 +353,15 @@ 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 = [...sysPaths], savedUser = [...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,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
|
||||||
@@ -19,6 +19,12 @@ vi.mock('@/i18n', () => ({
|
|||||||
}) },
|
}) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
|
|
||||||
|
function pe(s: string, enabled: boolean = true): PathEntry {
|
||||||
|
return { path: s, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { UndoRedoManager, TargetType } from '@/core/undo-redo';
|
import { UndoRedoManager, TargetType } from '@/core/undo-redo';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -50,7 +56,7 @@ describe('app-store CRUD', () => {
|
|||||||
it('addPath 追加到 sysPaths', () => {
|
it('addPath 追加到 sysPaths', () => {
|
||||||
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
|
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.sysPaths).toEqual(['C:\\test']);
|
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\test']);
|
||||||
expect(s.isModified).toBe(true);
|
expect(s.isModified).toBe(true);
|
||||||
expect(s.undoRedo.historyLength).toBe(1);
|
expect(s.undoRedo.historyLength).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -58,7 +64,7 @@ describe('app-store CRUD', () => {
|
|||||||
it('addPath 追加到 userPaths', () => {
|
it('addPath 追加到 userPaths', () => {
|
||||||
useAppStore.getState().addPath('D:\\user', TargetType.USER);
|
useAppStore.getState().addPath('D:\\user', TargetType.USER);
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.userPaths).toEqual(['D:\\user']);
|
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\user']);
|
||||||
expect(s.sysPaths).toEqual([]);
|
expect(s.sysPaths).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ describe('app-store CRUD', () => {
|
|||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('C:\\old', TargetType.SYSTEM);
|
store.addPath('C:\\old', TargetType.SYSTEM);
|
||||||
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
|
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['C:\\new']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\new']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('editPath 越界 index 无崩溃', () => {
|
it('editPath 越界 index 无崩溃', () => {
|
||||||
@@ -81,7 +87,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('B', TargetType.SYSTEM);
|
store.addPath('B', TargetType.SYSTEM);
|
||||||
store.addPath('C', TargetType.SYSTEM);
|
store.addPath('C', TargetType.SYSTEM);
|
||||||
store.deletePaths([1], TargetType.SYSTEM);
|
store.deletePaths([1], TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['A', 'C']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||||
expect(useAppStore.getState().selectedIndices).toEqual([]);
|
expect(useAppStore.getState().selectedIndices).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +98,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('C', TargetType.USER);
|
store.addPath('C', TargetType.USER);
|
||||||
store.addPath('D', TargetType.USER);
|
store.addPath('D', TargetType.USER);
|
||||||
store.deletePaths([1, 3], TargetType.USER);
|
store.deletePaths([1, 3], TargetType.USER);
|
||||||
expect(useAppStore.getState().userPaths).toEqual(['A', 'C']);
|
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
||||||
@@ -102,16 +108,16 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('C', TargetType.SYSTEM);
|
store.addPath('C', TargetType.SYSTEM);
|
||||||
store.addPath('D', TargetType.SYSTEM);
|
store.addPath('D', TargetType.SYSTEM);
|
||||||
store.deletePaths([1, 3], TargetType.SYSTEM);
|
store.deletePaths([1, 3], TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['A', 'C']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||||
useAppStore.getState().undo();
|
useAppStore.getState().undo();
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['A', 'B', 'C', 'D']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'B', 'C', 'D']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moveUp index=0 无操作', () => {
|
it('moveUp index=0 无操作', () => {
|
||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('A', TargetType.SYSTEM);
|
store.addPath('A', TargetType.SYSTEM);
|
||||||
store.moveUp(0, TargetType.SYSTEM);
|
store.moveUp(0, TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['A']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moveUp 正常交换位置', () => {
|
it('moveUp 正常交换位置', () => {
|
||||||
@@ -119,7 +125,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('A', TargetType.SYSTEM);
|
store.addPath('A', TargetType.SYSTEM);
|
||||||
store.addPath('B', TargetType.SYSTEM);
|
store.addPath('B', TargetType.SYSTEM);
|
||||||
store.moveUp(1, TargetType.SYSTEM);
|
store.moveUp(1, TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['B', 'A']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['B', 'A']);
|
||||||
expect(useAppStore.getState().selectedIndices).toEqual([0]);
|
expect(useAppStore.getState().selectedIndices).toEqual([0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +133,7 @@ describe('app-store CRUD', () => {
|
|||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('A', TargetType.USER);
|
store.addPath('A', TargetType.USER);
|
||||||
store.moveDown(0, TargetType.USER);
|
store.moveDown(0, TargetType.USER);
|
||||||
expect(useAppStore.getState().userPaths).toEqual(['A']);
|
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleanPaths 移除无效路径并返回 removed', () => {
|
it('cleanPaths 移除无效路径并返回 removed', () => {
|
||||||
@@ -137,7 +143,7 @@ describe('app-store CRUD', () => {
|
|||||||
// is_valid_path_format 拒绝全标点路径
|
// is_valid_path_format 拒绝全标点路径
|
||||||
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
|
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
|
||||||
expect(removed).toEqual([':::invalid:::']);
|
expect(removed).toEqual([':::invalid:::']);
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['C:\\valid']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\valid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replacePaths 整体替换列表', () => {
|
it('replacePaths 整体替换列表', () => {
|
||||||
@@ -145,7 +151,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('old1', TargetType.USER);
|
store.addPath('old1', TargetType.USER);
|
||||||
store.addPath('old2', TargetType.USER);
|
store.addPath('old2', TargetType.USER);
|
||||||
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
||||||
expect(useAppStore.getState().userPaths).toEqual(['new1', 'new2', 'new3']);
|
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['new1', 'new2', 'new3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearPaths 清空列表', () => {
|
it('clearPaths 清空列表', () => {
|
||||||
@@ -181,7 +187,7 @@ describe('undo/redo', () => {
|
|||||||
store.addPath('test', TargetType.SYSTEM);
|
store.addPath('test', TargetType.SYSTEM);
|
||||||
store.undo();
|
store.undo();
|
||||||
store.redo();
|
store.redo();
|
||||||
expect(useAppStore.getState().sysPaths).toEqual(['test']);
|
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['test']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('undo/redo 正确更新 isModified', () => {
|
it('undo/redo 正确更新 isModified', () => {
|
||||||
@@ -208,8 +214,8 @@ describe('loadPaths', () => {
|
|||||||
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
|
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
|
||||||
await useAppStore.getState().loadPaths();
|
await useAppStore.getState().loadPaths();
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.sysPaths).toEqual(['C:\\sys1', 'C:\\sys2']);
|
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
|
||||||
expect(s.userPaths).toEqual(['D:\\usr1']);
|
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\usr1']);
|
||||||
expect(s.isLoading).toBe(false);
|
expect(s.isLoading).toBe(false);
|
||||||
expect(s.isModified).toBe(false);
|
expect(s.isModified).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -228,7 +234,7 @@ describe('savePaths', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resetStore();
|
resetStore();
|
||||||
useAppStore.setState({ sysPaths: ['A'], userPaths: ['B'] });
|
useAppStore.setState({ sysPaths: [pe('A')], userPaths: [pe('B')] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('保存成功', async () => {
|
it('保存成功', async () => {
|
||||||
@@ -254,6 +260,7 @@ describe('savePaths', () => {
|
|||||||
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
||||||
let resolveAll: (v: unknown) => void;
|
let resolveAll: (v: unknown) => void;
|
||||||
const pending = new Promise((r) => { resolveAll = r; });
|
const pending = new Promise((r) => { resolveAll = r; });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
mockedInvoke.mockReturnValue(pending as any);
|
mockedInvoke.mockReturnValue(pending as any);
|
||||||
|
|
||||||
// 第一次调用(不等它完成,停在 Promise.allSettled)
|
// 第一次调用(不等它完成,停在 Promise.allSettled)
|
||||||
@@ -284,8 +291,8 @@ describe('initialize', () => {
|
|||||||
await useAppStore.getState().initialize();
|
await useAppStore.getState().initialize();
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.isAdmin).toBe(true);
|
expect(s.isAdmin).toBe(true);
|
||||||
expect(s.sysPaths).toEqual(['S1']);
|
expect(s.sysPaths.map(e => e.path)).toEqual(['S1']);
|
||||||
expect(s.userPaths).toEqual(['U1']);
|
expect(s.userPaths.map(e => e.path)).toEqual(['U1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('非管理员初始化进入只读模式', async () => {
|
it('非管理员初始化进入只读模式', async () => {
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import {
|
|||||||
detectExportFormat,
|
detectExportFormat,
|
||||||
flattenImportResult,
|
flattenImportResult,
|
||||||
} from '../../src/core/import-export';
|
} from '../../src/core/import-export';
|
||||||
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
|
|
||||||
|
function pe(s: string, enabled: boolean = true): PathEntry {
|
||||||
|
return { path: s, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
const sampleData = {
|
const sampleData = {
|
||||||
system: ['C:\\Windows', 'C:\\Program Files'],
|
system: [pe('C:\\Windows'), pe('C:\\Program Files')],
|
||||||
user: ['C:\\Users\\me\\AppData'],
|
user: [pe('C:\\Users\\me\\AppData')],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('exportToJson', () => {
|
describe('exportToJson', () => {
|
||||||
@@ -21,15 +26,18 @@ describe('exportToJson', () => {
|
|||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
expect(parsed.version).toBe('1.0');
|
expect(parsed.version).toBe('1.0');
|
||||||
expect(parsed.type).toBe('PathEditor');
|
expect(parsed.type).toBe('PathEditor');
|
||||||
expect(parsed.system).toEqual(sampleData.system);
|
expect(parsed.system).toEqual(sampleData.system.map(e => e.path));
|
||||||
expect(parsed.user).toEqual(sampleData.user);
|
expect(parsed.user).toEqual(sampleData.user.map(e => e.path));
|
||||||
expect(parsed.exported).toBeDefined();
|
expect(parsed.exported).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('importFromJson', () => {
|
describe('importFromJson', () => {
|
||||||
it('正确导入 JSON', () => {
|
it('正确导入 JSON', () => {
|
||||||
const json = JSON.stringify(sampleData);
|
const json = JSON.stringify({
|
||||||
|
system: sampleData.system.map(e => e.path),
|
||||||
|
user: sampleData.user.map(e => e.path),
|
||||||
|
});
|
||||||
const result = importFromJson(json);
|
const result = importFromJson(json);
|
||||||
expect(result.system).toEqual(sampleData.system);
|
expect(result.system).toEqual(sampleData.system);
|
||||||
expect(result.user).toEqual(sampleData.user);
|
expect(result.user).toEqual(sampleData.user);
|
||||||
@@ -38,7 +46,7 @@ describe('importFromJson', () => {
|
|||||||
it('过滤空字符串', () => {
|
it('过滤空字符串', () => {
|
||||||
const json = JSON.stringify({ system: ['C:\\', '', ' '], user: [] });
|
const json = JSON.stringify({ system: ['C:\\', '', ' '], user: [] });
|
||||||
const result = importFromJson(json);
|
const result = importFromJson(json);
|
||||||
expect(result.system).toEqual(['C:\\']);
|
expect(result.system).toEqual([pe('C:\\')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,13 +60,13 @@ describe('exportToCsv', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('CSV 字段转义', () => {
|
it('CSV 字段转义', () => {
|
||||||
const data = { system: ['C:\\Path,with,commas'], user: [] };
|
const data = { system: [pe('C:\\Path,with,commas')], user: [] };
|
||||||
const csv = exportToCsv(data);
|
const csv = exportToCsv(data);
|
||||||
expect(csv).toContain('"C:\\Path,with,commas"');
|
expect(csv).toContain('"C:\\Path,with,commas"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CSV 双引号转义', () => {
|
it('CSV 双引号转义', () => {
|
||||||
const data = { system: ['Path with "quotes"'], user: [] };
|
const data = { system: [pe('Path with "quotes"')], user: [] };
|
||||||
const csv = exportToCsv(data);
|
const csv = exportToCsv(data);
|
||||||
expect(csv).toContain('"Path with ""quotes"""');
|
expect(csv).toContain('"Path with ""quotes"""');
|
||||||
});
|
});
|
||||||
@@ -68,8 +76,8 @@ describe('importFromCsv', () => {
|
|||||||
it('正确导入 CSV', () => {
|
it('正确导入 CSV', () => {
|
||||||
const csv = 'type,path\nsystem,C:\\Windows\nuser,C:\\AppData\n';
|
const csv = 'type,path\nsystem,C:\\Windows\nuser,C:\\AppData\n';
|
||||||
const result = importFromCsv(csv);
|
const result = importFromCsv(csv);
|
||||||
expect(result.system).toEqual(['C:\\Windows']);
|
expect(result.system).toEqual([pe('C:\\Windows')]);
|
||||||
expect(result.user).toEqual(['C:\\AppData']);
|
expect(result.user).toEqual([pe('C:\\AppData')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('跳过未知类型', () => {
|
it('跳过未知类型', () => {
|
||||||
@@ -82,7 +90,7 @@ describe('importFromCsv', () => {
|
|||||||
it('处理带引号的 CSV 字段', () => {
|
it('处理带引号的 CSV 字段', () => {
|
||||||
const csv = 'type,path\nsystem,"C:\\Path,With,Commas"';
|
const csv = 'type,path\nsystem,"C:\\Path,With,Commas"';
|
||||||
const result = importFromCsv(csv);
|
const result = importFromCsv(csv);
|
||||||
expect(result.system).toEqual(['C:\\Path,With,Commas']);
|
expect(result.system).toEqual([pe('C:\\Path,With,Commas')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,13 +98,13 @@ describe('importFromTxt', () => {
|
|||||||
it('逐行导入,跳过注释和空行', () => {
|
it('逐行导入,跳过注释和空行', () => {
|
||||||
const txt = '# 这是注释\nC:\\Windows\n\nD:\\Projects\n# 另一个注释';
|
const txt = '# 这是注释\nC:\\Windows\n\nD:\\Projects\n# 另一个注释';
|
||||||
const paths = importFromTxt(txt);
|
const paths = importFromTxt(txt);
|
||||||
expect(paths).toEqual(['C:\\Windows', 'D:\\Projects']);
|
expect(paths).toEqual([pe('C:\\Windows'), pe('D:\\Projects')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('跳过 BOM', () => {
|
it('跳过 BOM', () => {
|
||||||
const txt = 'C:\\Windows';
|
const txt = 'C:\\Windows';
|
||||||
const paths = importFromTxt(txt);
|
const paths = importFromTxt(txt);
|
||||||
expect(paths).toEqual(['C:\\Windows']);
|
expect(paths).toEqual([pe('C:\\Windows')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,9 +114,9 @@ describe('importFromContent', () => {
|
|||||||
const jsonContent = JSON.stringify({ system: ['C:\\Test'], user: [] });
|
const jsonContent = JSON.stringify({ system: ['C:\\Test'], user: [] });
|
||||||
const txtContent = 'C:\\Test';
|
const txtContent = 'C:\\Test';
|
||||||
|
|
||||||
expect(importFromContent(csvContent, 'test.csv').system).toEqual(['C:\\Test']);
|
expect(importFromContent(csvContent, 'test.csv').system).toEqual([pe('C:\\Test')]);
|
||||||
expect(importFromContent(jsonContent, 'test.json').system).toEqual(['C:\\Test']);
|
expect(importFromContent(jsonContent, 'test.json').system).toEqual([pe('C:\\Test')]);
|
||||||
expect(importFromContent(txtContent, 'test.txt').system).toEqual(['C:\\Test']);
|
expect(importFromContent(txtContent, 'test.txt').system).toEqual([pe('C:\\Test')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,23 +133,23 @@ describe('detectExportFormat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('flattenImportResult', () => {
|
describe('flattenImportResult', () => {
|
||||||
const data = { system: ['S1'], user: ['U1'] };
|
const data = { system: [pe('S1')], user: [pe('U1')] };
|
||||||
|
|
||||||
it('仅系统', () => {
|
it('仅系统', () => {
|
||||||
const r = flattenImportResult(data, 'system');
|
const r = flattenImportResult(data, 'system');
|
||||||
expect(r.system).toEqual(['S1']);
|
expect(r.system).toEqual([pe('S1')]);
|
||||||
expect(r.user).toEqual([]);
|
expect(r.user).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('仅用户', () => {
|
it('仅用户', () => {
|
||||||
const r = flattenImportResult(data, 'user');
|
const r = flattenImportResult(data, 'user');
|
||||||
expect(r.system).toEqual([]);
|
expect(r.system).toEqual([]);
|
||||||
expect(r.user).toEqual(['U1']);
|
expect(r.user).toEqual([pe('U1')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('两者都导入', () => {
|
it('两者都导入', () => {
|
||||||
const r = flattenImportResult(data, 'both');
|
const r = flattenImportResult(data, 'both');
|
||||||
expect(r.system).toEqual(['S1']);
|
expect(r.system).toEqual([pe('S1')]);
|
||||||
expect(r.user).toEqual(['U1']);
|
expect(r.user).toEqual([pe('U1')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { pathClean } from '../../src/core/path-manager';
|
import { pathClean } from '../../src/core/path-manager';
|
||||||
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
|
|
||||||
|
function pe(s: string, enabled: boolean = true): PathEntry {
|
||||||
|
return { path: s, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
const alwaysValid = () => true;
|
const alwaysValid = () => true;
|
||||||
const validateFn = (path: string) => !path.includes('Invalid');
|
const validateFn = (path: string) => !path.includes('Invalid');
|
||||||
|
|
||||||
describe('pathClean', () => {
|
describe('pathClean', () => {
|
||||||
it('移除无效路径', () => {
|
it('移除无效路径', () => {
|
||||||
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid'], validateFn);
|
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')], validateFn);
|
||||||
expect(kept).toEqual(['C:\\Valid', 'D:\\Valid']);
|
expect(kept.map(e => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||||
expect(removed).toEqual(['C:\\Invalid']);
|
expect(removed.map(e => e.path)).toEqual(['C:\\Invalid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('移除重复路径保留第一个', () => {
|
it('移除重复路径保留第一个', () => {
|
||||||
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Valid', 'D:\\Valid'], alwaysValid);
|
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Valid'), pe('D:\\Valid')], alwaysValid);
|
||||||
expect(kept.length).toBe(2);
|
expect(kept.length).toBe(2);
|
||||||
expect(removed.length).toBe(1);
|
expect(removed.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('全部有效无变化', () => {
|
it('全部有效无变化', () => {
|
||||||
const [kept, removed] = pathClean(['C:\\a', 'D:\\b'], alwaysValid);
|
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
|
||||||
expect(kept).toEqual(['C:\\a', 'D:\\b']);
|
expect(kept.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
|
||||||
expect(removed.length).toBe(0);
|
expect(removed.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('全部无效全部移除', () => {
|
it('全部无效全部移除', () => {
|
||||||
const [kept, removed] = pathClean(['C:\\Invalid1', 'C:\\Invalid2'], validateFn);
|
const [kept, removed] = pathClean([pe('C:\\Invalid1'), pe('C:\\Invalid2')], validateFn);
|
||||||
expect(kept.length).toBe(0);
|
expect(kept.length).toBe(0);
|
||||||
expect(removed.length).toBe(2);
|
expect(removed.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { UndoRedoManager, OperationType, TargetType, type OpRecord } from '../../src/core/undo-redo';
|
import { UndoRedoManager, OperationType, TargetType, type OpRecord } from '../../src/core/undo-redo';
|
||||||
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
|
|
||||||
function makeRecord(type: OperationType, target: TargetType, index: number, count: number, oldPaths: string[], newPaths: string[]): OpRecord {
|
function pe(s: string, enabled: boolean = true): PathEntry {
|
||||||
|
return { path: s, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecord(type: OperationType, target: TargetType, index: number, count: number, oldPaths: PathEntry[], newPaths: PathEntry[]): OpRecord {
|
||||||
return { type, target, index, count, oldPaths, newPaths };
|
return { type, target, index, count, oldPaths, newPaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('UndoRedoManager', () => {
|
describe('UndoRedoManager', () => {
|
||||||
let mgr: UndoRedoManager;
|
let mgr: UndoRedoManager;
|
||||||
let sys: string[];
|
let sys: PathEntry[];
|
||||||
let user: string[];
|
let user: PathEntry[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mgr = new UndoRedoManager(50);
|
mgr = new UndoRedoManager(50);
|
||||||
sys = ['C:\\Windows', 'C:\\Program Files'];
|
sys = [pe('C:\\Windows'), pe('C:\\Program Files')];
|
||||||
user = ['C:\\Users\\me\\AppData'];
|
user = [pe('C:\\Users\\me\\AppData')];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('初始不可撤销不可重做', () => {
|
it('初始不可撤销不可重做', () => {
|
||||||
@@ -22,14 +27,14 @@ describe('UndoRedoManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ADD 撤销/重做', () => {
|
it('ADD 撤销/重做', () => {
|
||||||
sys.push('C:\\NewPath');
|
sys.push(pe('C:\\NewPath'));
|
||||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE 撤销/重做', () => {
|
it('DELETE 撤销/重做', () => {
|
||||||
@@ -38,21 +43,21 @@ describe('UndoRedoManager', () => {
|
|||||||
sys.splice(0, 1);
|
sys.splice(0, 1);
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0][0]).toBe(removed);
|
expect(u[0][0].path).toBe(removed.path);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0]).toEqual(['C:\\Program Files']);
|
expect(r[0].map(e => e.path)).toEqual(['C:\\Program Files']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('EDIT 撤销/重做', () => {
|
it('EDIT 撤销/重做', () => {
|
||||||
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, ['C:\\Windows'], ['C:\\Edited']));
|
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, [pe('C:\\Windows')], [pe('C:\\Edited')]));
|
||||||
sys[0] = 'C:\\Edited';
|
sys[0] = pe('C:\\Edited');
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0][0]).toBe('C:\\Windows');
|
expect(u[0][0].path).toBe('C:\\Windows');
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0][0]).toBe('C:\\Edited');
|
expect(r[0][0].path).toBe('C:\\Edited');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MOVE_UP 撤销/重做', () => {
|
it('MOVE_UP 撤销/重做', () => {
|
||||||
@@ -60,10 +65,10 @@ describe('UndoRedoManager', () => {
|
|||||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0]).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
expect(r[0].map(e => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MOVE_DOWN 撤销/重做', () => {
|
it('MOVE_DOWN 撤销/重做', () => {
|
||||||
@@ -71,12 +76,12 @@ describe('UndoRedoManager', () => {
|
|||||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CLEAN 撤销/重做', () => {
|
it('CLEAN 撤销/重做', () => {
|
||||||
const old = [...sys];
|
const old = [...sys];
|
||||||
const cleaned = ['C:\\Windows'];
|
const cleaned = [pe('C:\\Windows')];
|
||||||
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
|
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
|
||||||
sys = cleaned;
|
sys = cleaned;
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ describe('UndoRedoManager', () => {
|
|||||||
|
|
||||||
it('IMPORT 撤销/重做', () => {
|
it('IMPORT 撤销/重做', () => {
|
||||||
const old = [...sys];
|
const old = [...sys];
|
||||||
const imported = ['C:\\New1', 'C:\\New2'];
|
const imported = [pe('C:\\New1'), pe('C:\\New2')];
|
||||||
mgr.push(makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, old, imported));
|
mgr.push(makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, old, imported));
|
||||||
sys = imported;
|
sys = imported;
|
||||||
|
|
||||||
@@ -113,24 +118,24 @@ describe('UndoRedoManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('新操作后截断重做分支', () => {
|
it('新操作后截断重做分支', () => {
|
||||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['first']));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe('first')]));
|
||||||
mgr.undo(sys, user);
|
mgr.undo(sys, user);
|
||||||
expect(mgr.canRedo()).toBe(true);
|
expect(mgr.canRedo()).toBe(true);
|
||||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['second']));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe('second')]));
|
||||||
expect(mgr.canRedo()).toBe(false);
|
expect(mgr.canRedo()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('超出最大历史容量时移除最旧记录', () => {
|
it('超出最大历史容量时移除最旧记录', () => {
|
||||||
const small = new UndoRedoManager(3);
|
const small = new UndoRedoManager(3);
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
small.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [`path_${i}`]));
|
small.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [pe(`path_${i}`)]));
|
||||||
}
|
}
|
||||||
expect(small.historyLength).toBe(3);
|
expect(small.historyLength).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
|
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
|
||||||
// 扩展初始数组
|
// 扩展初始数组
|
||||||
sys.push('C:\\Extra1', 'C:\\Extra2');
|
sys.push(pe('C:\\Extra1'), pe('C:\\Extra2'));
|
||||||
const old = [...sys];
|
const old = [...sys];
|
||||||
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
||||||
const removed = [sys[1], sys[3]];
|
const removed = [sys[1], sys[3]];
|
||||||
@@ -147,14 +152,26 @@ describe('UndoRedoManager', () => {
|
|||||||
expect(u[0]).toEqual(old);
|
expect(u[0]).toEqual(old);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('操作 USER 路径', () => {
|
it('操作 USER 路径', () => {
|
||||||
user.push('C:\\NewUserPath');
|
user.push(pe('C:\\NewUserPath'));
|
||||||
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[1]).toEqual(['C:\\Users\\me\\AppData']);
|
expect(u[1].map(e => e.path)).toEqual(['C:\\Users\\me\\AppData']);
|
||||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOGGLE 撤销/重做', () => {
|
||||||
|
sys[0] = pe('C:\\Windows', false);
|
||||||
|
mgr.push(makeRecord(OperationType.TOGGLE, TargetType.SYSTEM, 0, 1,
|
||||||
|
[pe('C:\\Windows', true)], [pe('C:\\Windows', false)]));
|
||||||
|
|
||||||
|
const u = mgr.undo(sys, user)!;
|
||||||
|
expect(u[0][0].enabled).toBe(true);
|
||||||
|
|
||||||
|
const r = mgr.redo(...u)!;
|
||||||
|
expect(r[0][0].enabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
exclude: ['e2e/**', 'node_modules/**', 'src-tauri/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user