Compare commits

...

4 Commits

Author SHA1 Message Date
Serendipity d649b03c3b Merge pull request #9 from LHY0125/v5.1
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
V5.1
2026-06-19 19:25:19 +08:00
Serendipity 9453006310 chore: 同步 v5.0 基础设施完善到 v5.1
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
从 v5.0 cherry-pick 的开源项目基础设施改进:

新增配置文件:
- .editorconfig, .gitattributes, .prettierrc, .markdownlint.json
- commitlint.config.js

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

新增文档:
- ROADMAP.md — 路线图
- SUPPORT.md — 帮助指南
- docs/screenshots/ — 应用截图

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

CI 强化:
- 新增 Prettier 格式检查
- 新增 Vitest 覆盖率 + Codecov 上报
- 保留 v5.1 已有的 rust-cache + jsdom 全局环境

修复:
- index.html 标题 v4.0 → v5.1
- PathEditDialog set-state-in-effect 改用 useRef prevOpen 守卫
- merge-preview.test.tsx no-explicit-any 修复
- 所有 TS/TSX 文件 Prettier 格式化统一

v5.1 保留特性:
- @tanstack/react-virtual 虚拟滚动
- jsdom 全局测试环境
- Swatinem/rust-cache CI 加速
- 105 测试全部通过
2026-06-19 19:24:03 +08:00
Serendipity 60de924b08 build, fix, feat, refactor: 优化长列表性能,新增注册表并发校验,升级v5.1.0
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
- 前端引入@tanstack/react-virtual虚拟列表库,重构PathTable与MergePreview组件,优化大量路径条目下的渲染性能
- 为后端注册表保存接口添加原始路径比对逻辑,防止并发修改导致的配置覆盖,同步更新前端保存逻辑传递原始路径参数
- 替换core模块手动编写的Windows API FFI声明为windows-sys官方库,简化代码维护
- 完善单元测试,新增空数组处理、边界场景的测试用例
- 更新项目依赖与锁定文件,将版本升级至v5.1.0
- 新增项目代码架构审查文档
2026-05-31 15:16:05 +08:00
Serendipity a9b36a6f47 重构(应用状态存储): 调整savePaths返回结构化结果并更新所有调用处
- 新增SaveResult类型统一标准化保存操作的结果状态
- 修改savePaths函数返回结构化结果而非布尔值,完善长路径超限、部分失败等场景的处理逻辑,部分失败时重新加载路径避免状态偏移
- 更新useAppActions与ProfileDialog的保存逻辑,适配新API并添加长路径确认弹窗
- 补充相关测试用例,修正导入导出测试的版本号预期
2026-05-31 14:48:03 +08:00
58 changed files with 3131 additions and 740 deletions
+24
View File
@@ -0,0 +1,24 @@
# EditorConfig — 跨编辑器统一代码风格
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = crlf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.{rs,toml}]
indent_size = 4
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab
+32
View File
@@ -0,0 +1,32 @@
# Git 行尾符规范化
# 本仓库统一 CRLFWindows 原生项目)
# 源码文本文件
*.ts text eol=crlf
*.tsx text eol=crlf
*.js text eol=crlf
*.json text eol=crlf
*.html text eol=crlf
*.css text eol=crlf
*.md text eol=crlf
*.rs text eol=crlf
*.toml text eol=crlf
*.yml text eol=crlf
*.yaml text eol=crlf
*.svg text eol=crlf
*.txt text eol=crlf
*.editorconfig text eol=crlf
*.gitattributes text eol=crlf
*.gitignore text eol=crlf
LICENSE text eol=crlf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.dll binary
*.exe binary
*.nsis binary
+20
View File
@@ -0,0 +1,20 @@
# 代码所有者 — 自动分配 PR 审查
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# 全局所有者
* @LHY0125
# Rust 代码
/core/ @LHY0125
/cli/ @LHY0125
/gui/ @LHY0125
/Cargo.toml @LHY0125
/rust-toolchain.toml @LHY0125
# 前端代码
/src/ @LHY0125
/tests/ @LHY0125
/e2e/ @LHY0125
# CI/CD 和配置文件
/.github/ @LHY0125
+5
View File
@@ -0,0 +1,5 @@
# 开源赞助
# 支持 PathEditor 的开发
github: LHY0125
# 如需定制功能或商业授权,请通过 GitHub Issues 联系
+73
View File
@@ -0,0 +1,73 @@
# Dependabot 自动依赖更新配置
# https://docs.github.com/code-security/dependabot/dependabot-version-updates
version: 2
updates:
# npm 前端依赖
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
versioning-strategy: "auto"
allow:
- dependency-type: "all"
labels:
- "dependencies"
- "javascript"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 5
groups:
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
tauri:
patterns:
- "@tauri-apps/*"
testing:
patterns:
- "@testing-library/*"
- "@playwright/test"
- "vitest"
- "jsdom"
eslint:
patterns:
- "eslint"
- "eslint-plugin-*"
- "typescript-eslint"
- "globals"
- "@eslint/js"
# Cargo Rust 依赖
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
labels:
- "dependencies"
- "rust"
commit-message:
prefix: "chore(deps)"
prefix-development: "chore(deps-dev)"
open-pull-requests-limit: 3
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "ci(deps)"
+18 -7
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
frontend:
name: 前端检查 (TypeScript + Lint + Test)
name: 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -24,23 +24,37 @@ jobs:
- run: npm ci
- name: Prettier 格式检查
run: npx prettier --check "src/**/*.{ts,tsx}" "tests/**/*.{ts,tsx}" "e2e/**/*.ts"
- name: TypeScript 类型检查
run: npx tsc -b --noEmit
- name: ESLint
run: npx eslint src/ tests/ e2e/
- name: Vitest 测试
run: npm test
- name: Vitest 测试 + 覆盖率
run: npx vitest run --coverage
- name: 上传覆盖率到 Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/cobertura-coverage.xml
flags: frontend
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
rust:
name: Rust 检查 (Check + Clippy + Test)
name: Rust 检查 (格式 + Check + Clippy + Test)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Cargo Format Check
run: cargo fmt --check
- name: Cargo Check
run: cargo check
@@ -49,6 +63,3 @@ jobs:
- name: Cargo Test
run: cargo test
- name: Cargo Format Check
run: cargo fmt --check
+21 -1
View File
@@ -12,6 +12,18 @@ dist
dist-ssr
*.local
# Coverage
coverage/
*.lcov
# Sync conflicts
*.sync-conflict-*
# Test artifacts
test-results/
playwright-report/
.nyc_output/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -22,9 +34,17 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# AI assistant
.claude/
.codegraph/
CLAUDE.md
# Platform
e2e/debug-screenshot.png
test-results/
target/
# Archive
*.zip
*.7z
*.tar.gz
+1
View File
@@ -0,0 +1 @@
npx --no -- commitlint --edit $1
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+8
View File
@@ -0,0 +1,8 @@
{
"default": true,
"MD013": false,
"MD033": {
"allowed_elements": ["img", "br", "kbd", "summary", "details"]
},
"MD041": false
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-ssr
target
*.local
*.log
test-results
coverage
Cargo.lock
package-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "crlf",
"arrowParens": "always",
"bracketSpacing": true
}
Generated
+1
View File
@@ -2386,6 +2386,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"windows-sys 0.61.2",
"winreg 0.52.0",
]
+41 -19
View File
@@ -11,11 +11,33 @@
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
<img src="https://img.shields.io/badge/tests-157%20passed-brightgreen" alt="tests">
<a href="https://codecov.io/gh/LHY0125/PathEditor"><img src="https://codecov.io/gh/LHY0125/PathEditor/branch/v5.1/graph/badge.svg" alt="coverage"></a>
<img src="https://img.shields.io/badge/platform-Windows%2010%2B-0078D6" alt="platform">
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
</p>
---
## 截图
### 主界面
![主界面](docs/screenshots/main-window.png)
### 路径编辑
![路径编辑](docs/screenshots/path-edit.png)
### 冲突检测
![冲突检测](docs/screenshots/conflict-analysis.png)
### CLI 命令行
![CLI](docs/screenshots/cli-demo.png)
---
## 简介
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
@@ -251,18 +273,18 @@ npx tauri build
### 技术栈
| 层 | 技术 |
|---|---|
| 前端框架 | React 19 + TypeScript (strict) |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 核心库 | Rust workspace (core + gui + cli) |
| 前端测试 | Vitest (100 个测试) |
| Rust 测试 | cargo test (57 个测试) |
| 构建 | Vite + Cargo |
| 打包 | NSIS |
| 层 | 技术 |
| --------- | --------------------------------- |
| 前端框架 | React 19 + TypeScript (strict) |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 核心库 | Rust workspace (core + gui + cli) |
| 前端测试 | Vitest (100 个测试) |
| Rust 测试 | cargo test (57 个测试) |
| 构建 | Vite + Cargo |
| 打包 | NSIS |
### 项目结构
@@ -291,15 +313,15 @@ docs/ # 审查文档
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| 快捷键 | 功能 |
| -------- | -------- |
| `Ctrl+N` | 新建路径 |
| `Ctrl+S` | 保存 |
| `Ctrl+Z` | 撤销 |
| `Ctrl+Y` | 重做 |
| `Ctrl+F` | 搜索 |
| `Ctrl+S` | 保存 |
| `Ctrl+Z` | 撤销 |
| `Ctrl+Y` | 重做 |
| `Ctrl+F` | 搜索 |
| `Delete` | 删除选中 |
| `F1` | 帮助 |
| `F1` | 帮助 |
## 贡献
+48
View File
@@ -0,0 +1,48 @@
# 路线图
PathEditor 的未来发展方向。
## v5.1 (下一个版本)
- [ ] **CLI 模块化**`cli/src/main.rs` 拆分为 `commands/` 子模块
- [ ] **自动更新** — 内置 Tauri updater,无需手动下载安装包
- [ ] **深色模式优化** — 对齐 Windows 系统主题自动切换
- [ ] **性能优化** — 虚拟滚动支持超长 PATH 列表(1000+ 条目)
## v5.2
- [ ] **PATH 历史快照** — 保存每次修改的时间线,支持回退到任意历史节点
- [ ] **规则引擎** — 自定义 PATH 整理规则(如「所有 Python 路径放最前」)
- [ ] **收藏夹** — 常用路径快速添加
- [ ] **冲突解决方案引导** — 可视化的可执行文件冲突对比与解决建议
## v6.0 (长期)
- [ ] **跨平台支持** — 适配 Linux (`/etc/environment` + `~/.profile`) 和 macOS (`path_helper`)
- [ ] **Web 管理面板** — 远程管理多台 Windows 服务器的 PATH 环境变量
- [ ] **插件系统** — 第三方扩展生态(如 Anaconda/VSCode/VS 自动检测与配置)
- [ ] **Windows Package Manager 集成** — 与 winget/chocolatey 联动,检测包管理器安装的路径
## 已交付
### v5.0.0
- ✅ Cargo workspace 三层架构 (core + gui + cli)
- ✅ CLI 命令行工具 (18 条命令)
- ✅ 冲突检测 + 工具清单
- ✅ 配置文件管理
- ✅ 撤销/重做 (10 种操作)
- ✅ 中英双语界面
- ✅ CI/CD 自动化
### v4.x 系列
- ✅ Tauri 2.x 重写
- ✅ 路径验证 (红色/橙色标记)
- ✅ 导入/导出 JSON/CSV/TXT
- ✅ 深色/浅色模式
- ✅ 全局键盘快捷键
---
欢迎通过 [Issues](https://github.com/LHY0125/PathEditor/issues) 提交功能建议!
+45
View File
@@ -0,0 +1,45 @@
# 获取帮助
## 📖 文档
- [README](README.md) — 项目简介、功能列表、安装指南
- [CONTRIBUTING](CONTRIBUTING.md) — 贡献指南
- [CHANGELOG](CHANGELOG.md) — 版本变更记录
- [ROADMAP](ROADMAP.md) — 未来规划
- [SECURITY](SECURITY.md) — 安全政策
## 🐛 报告 Bug
1. 先搜索 [Issues](https://github.com/LHY0125/PathEditor/issues) 确认未被报告
2. 使用 **Bug Report** 模板创建新 Issue
3. 提供系统信息(Windows 版本、PathEditor 版本)
4. 附上复现步骤和截图
## 💡 功能建议
1. 检查 [ROADMAP](ROADMAP.md) 确认不在已有计划中
2. 使用 **Feature Request** 模板创建新 Issue
3. 描述使用场景和期望行为
## ❓ 常见问题
### CLI 命令找不到?
```bash
patheditor --help
```
确保已通过 `cargo install --path cli` 安装,且 `~/.cargo/bin` 在 PATH 中。
### 提示权限不足?
编辑系统 PATH 需要管理员权限。右键以管理员身份运行,或使用 CLI `patheditor check-admin` 检测。
### 保存后环境变量未生效?
PathEditor 会自动广播 `WM_SETTINGCHANGE`,但部分程序需要手动重启才能识别新 PATH。
## 📧 联系
- GitHub Issues: [LHY0125/PathEditor](https://github.com/LHY0125/PathEditor/issues)
- 安全问题: 参见 [SECURITY.md](SECURITY.md)
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('@commitlint/types').UserConfig} */
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'perf', 'ci', 'style', 'revert'],
],
'subject-case': [0],
},
};
+1
View File
@@ -13,3 +13,4 @@ log = "0.4"
winreg = "0.52"
dirs = "5"
chrono = "0.4"
windows-sys = { version = "0.61.2", features = ["Win32_System_Environment", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
+7 -24
View File
@@ -1,3 +1,7 @@
use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
use winreg::enums::*;
use winreg::RegKey;
@@ -26,6 +30,7 @@ pub fn validate_path(path: &str) -> bool {
}
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin
/// 包含 % 的路径(环境变量路径)无法展开,返回原始路径
pub fn expand_env_vars(path: &str) -> String {
if !path.contains('%') {
return path.to_string();
@@ -46,7 +51,7 @@ pub fn expand_env_vars(path: &str) -> String {
// SAFETY: buffer 容量为 requiredAPI 返回的精确大小),wide_path 以 null 结尾,
// 且两个指针指向不同的内存区域,不存在重叠
let mut buffer: Vec<u16> = vec![0; required as usize];
let mut buffer = vec![0_u16; required as usize];
let result =
unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) };
@@ -73,10 +78,6 @@ fn decode_utf16_preserving(v: &[u16]) -> String {
/// 广播环境变量更改通知(WM_SETTINGCHANGE
/// 广播 `WM_SETTINGCHANGE` 通知系统环境变量已变更
pub fn broadcast_env_change() {
const HWND_BROADCAST: isize = 0xFFFF;
const WM_SETTINGCHANGE: u32 = 0x001A;
const SMTO_ABORTIFHUNG: u32 = 0x0002;
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
@@ -84,7 +85,7 @@ pub fn broadcast_env_change() {
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
let result = unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
HWND_BROADCAST as _,
WM_SETTINGCHANGE,
0,
env_str.as_ptr() as isize,
@@ -101,24 +102,6 @@ pub fn broadcast_env_change() {
}
}
// ── 外部 FFI 声明 ──
extern "system" {
/// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-expandenvironmentstringsw
fn ExpandEnvironmentStringsW(lpSrc: *const u16, lpDst: *mut u16, nSize: u32) -> u32;
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw
fn SendMessageTimeoutW(
hWnd: isize,
Msg: u32,
wParam: usize,
lParam: isize,
fuFlags: u32,
uTimeout: u32,
lpdwResult: *mut usize,
) -> isize;
}
#[cfg(test)]
mod tests {
use super::*;
+69
View File
@@ -0,0 +1,69 @@
# PathEditor v5.0 代码与架构审查报告
## 1. 项目概览
PathEditor v5.0 是一个功能完善的 Windows 系统环境变量 (PATH) 编辑器,支持 GUI 与 CLI 双模式。
技术栈选型现代化且合理:
- **后端 / 核心逻辑**Rust (Cargo Workspace)
- **GUI 框架**Tauri 2.x
- **前端**React 19 + TypeScript + Zustand
整体项目结构清晰,职责划分明确,严格遵循了前后端分离与核心逻辑无平台依赖的设计原则。
## 2. 架构设计审查
### 2.1 Cargo Workspace 三层架构
项目采用了经典的 Cargo Workspace 模式,分为三层:
- `core`: 纯 Rust 库 crate,包含所有的核心业务逻辑(注册表读写、备份、配置文件管理、路径验证与清理等)。该层**完全不依赖** Tauri 或 CLI 库,极大地提高了代码的复用性和可测试性。
- `gui`: Tauri 桌面应用。仅作为薄包装层(Thin Wrapper),通过 `#[tauri::command]``core` 的功能暴露为 IPC 接口供前端调用。
- `cli`: 命令行工具层。依赖 `core``clap` 库,直接提供命令行交互能力。
**审查结论**:架构设计非常优秀。核心逻辑解耦彻底,无论是 GUI 还是 CLI 都能复用同一套安全、经过测试的核心代码。
### 2.2 IPC 通信与状态同步
前端与 Rust 后端通过 Tauri IPC 进行通信。
- 所有的错误处理均通过 `Result<T, String>` 返回,前端通过 `Promise` 捕获并处理,用户体验良好。
- 针对非事务性的双写操作(如同时保存系统和用户 PATH),前端 `app-store.ts` 中使用了 `Promise.allSettled`。当发生部分成功(Partial Success)时,能正确捕获并重新加载注册表状态,避免了前端内存状态与后端注册表状态的漂移(State Drift)。
## 3. 后端代码审查 (Rust)
### 3.1 核心逻辑 (`core`)
- **安全性与健壮性**
-`registry.rs` 中,严格检查了路径字符串的 Null 字节,以及 32767 个字符的 Windows 注册表长度上限,防止缓冲区溢出或写入失败。
- 使用了安全的 `winreg` 库进行注册表操作。
- **FFI 调用**
-`system.rs` 中调用 Windows API(如 `ExpandEnvironmentStringsW``SendMessageTimeoutW`)时,对 `unsafe` 代码块进行了详尽的 SAFETY 注释。
- 能够妥善处理 UTF-16 编码和解码,保留非法码点避免丢失路径信息,细节处理非常到位。
### 3.2 命令行工具 (`cli`)
- **原子性与并发安全**
- 在 CLI 的 `verify_and_save` 逻辑中,写入前会重新读取注册表并与原始状态对比。如果不一致,则拒绝写入并报错退出。这有效地防止了并发情况下的配置覆盖问题。
- **用户体验**
- 命令设计符合直觉,支持 `--dry-run` 预览以及 JSON 格式输出,方便与其他脚本集成。
## 4. 前端代码审查 (React + TypeScript)
### 4.1 状态管理 (`app-store.ts`)
- 使用 `Zustand` 进行全局状态管理,状态树设计合理,避免了 React Context 可能带来的不必要重渲染。
- 实现了完善的 `UndoRedoManager`,将每一步操作抽象为 `OperationType`,支持撤销/重做功能,这对于编辑器类应用来说是核心体验的加分项。
- `isSaving` 状态守卫有效防止了用户双击保存按钮引发的并发竞争。
### 4.2 UI 与逻辑分离
- 业务逻辑抽象到 `src/core` 目录下(如 `path-manager.ts`, `validation.ts`),UI 组件仅负责渲染和事件绑定。
- `useAppActions.ts` 钩子巧妙地将组件层与 Store 状态操作解耦,使得组件代码极其整洁。
## 5. 改进建议 (Recommendations)
虽然当前代码质量已经很高,但仍有以下几个方面可以进一步优化:
1. **Rust FFI 维护性**
当前 `system.rs` 中手动声明了 `extern "system"` 函数。建议引入 `windows-rs``windows-sys` 库,这能提供微软官方维护的安全的 API 绑定,减少手动编写 FFI 签名带来的维护成本和潜在错误。
2. **GUI 保存的并发安全 (Race Condition)**
CLI 已经实现了保存前的二次状态比对(`verify_and_save`),但在 `gui/src/commands/registry.rs` 中,直接调用了 `save_system_paths`。如果在用户打开 GUI 修改期间,另一个进程修改了注册表,GUI 保存时可能会覆盖该修改。建议在 GUI 的 IPC 保存接口中,也引入类似 CLI 的版本校验(例如传入 `expected_original_paths` 进行比对)。
3. **前端单元测试覆盖**
核心逻辑如 `undo-redo.ts``path-manager.ts` 纯函数特性明显,建议在 `tests/unit/` 下增加对这些文件的边界用例测试,确保复杂编辑操作下状态不崩溃。
4. **长列表性能**
如果 PATH 环境变量条目非常多(虽然实际场景中一般在 100 条以内),React 渲染完整列表可能会有微小延迟。当前规模下无影响,但若未来考虑显示大量工具链路径扫描结果,可引入虚拟列表(Virtual List)。
## 总结
PathEditor v5.0 的代码库是一个优秀的 Rust + Tauri + React 实践范例。它具有清晰的三层架构、严格的类型和边界检查、以及良好的错误处理机制,整体架构稳健且易于长期维护。
+1
View File
@@ -0,0 +1 @@
# 截图目录
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+7 -5
View File
@@ -2,11 +2,13 @@ import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => {
await page.addInitScript(createIpcMock({
load_system_paths: ['C:\\Windows', 'invalid_path', 'C:\\Temp'],
load_user_paths: [],
validate_path: false,
}));
await page.addInitScript(
createIpcMock({
load_system_paths: ['C:\\Windows', 'invalid_path', 'C:\\Temp'],
load_user_paths: [],
validate_path: false,
}),
);
await page.goto('/');
});
+14 -2
View File
@@ -9,10 +9,22 @@ pub fn load_user_paths() -> Result<Vec<String>, String> {
registry::load_user_paths()
}
#[tauri::command]
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
pub fn save_system_paths(paths: Vec<String>, original: Option<Vec<String>>) -> Result<(), String> {
if let Some(orig) = original {
let current = registry::load_system_paths()?;
if current != orig {
return Err("注册表已被其他进程修改,请重新加载后重试".to_string());
}
}
registry::save_system_paths(paths)
}
#[tauri::command]
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
pub fn save_user_paths(paths: Vec<String>, original: Option<Vec<String>>) -> Result<(), String> {
if let Some(orig) = original {
let current = registry::load_user_paths()?;
if current != orig {
return Err("注册表已被其他进程修改,请重新加载后重试".to_string());
}
}
registry::save_user_paths(paths)
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PathEditor v4.0</title>
<title>PathEditor v5.1</title>
</head>
<body>
<div id="root"></div>
+1428 -4
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -3,20 +3,33 @@
"private": true,
"version": "5.1.0",
"type": "module",
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"*.{json,md,css,html}": [
"prettier --write"
]
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "cargo fmt",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"format:rust": "cargo fmt",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --config e2e/playwright.config.ts"
"test:e2e": "playwright test --config e2e/playwright.config.ts",
"prepare": "husky"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-virtual": "^3.13.26",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"i18next": "^26.2.0",
@@ -28,9 +41,12 @@
"zustand": "^5.0.13"
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
@@ -41,7 +57,10 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.4.0",
"prettier": "^3.8.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
+15 -4
View File
@@ -37,7 +37,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
const prevOpen = useRef(false);
useEffect(() => {
if (!open) { prevOpen.current = false; return; }
if (!open) {
prevOpen.current = false;
return;
}
if (prevOpen.current) return;
prevOpen.current = true;
setLoading(true);
@@ -67,7 +70,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
<Modal open={open} onClose={onClose}>
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
{/* 标题栏 */}
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
<div
className="flex items-center justify-between px-5 py-3 border-b"
style={{ borderColor: 'var(--app-border)' }}
>
<h2 className="text-base font-semibold">{t('analyze.title')}</h2>
<div className="flex gap-1">
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
@@ -89,7 +95,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
{/* 内容 */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm" style={{ color: 'var(--app-fg)', opacity: 0.6 }}>
<div
className="flex items-center justify-center py-12 text-sm"
style={{ color: 'var(--app-fg)', opacity: 0.6 }}
>
{t('analyze.scanning')}
</div>
) : tab === 'conflicts' ? (
@@ -215,5 +224,7 @@ function EmptyHint({ text }: { text: string }) {
function getEnabledPaths(): string[] {
const { sysPaths, userPaths } = useAppStore.getState();
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path);
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map(
(e) => e.path,
);
}
+12 -3
View File
@@ -1,7 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
interface HelpDialogProps { open: boolean; onClose: () => void; }
interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const { t } = useTranslation();
@@ -9,9 +12,15 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
return (
<Modal open={open} onClose={onClose}>
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">{t('help.content')}</pre>
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">
{t('help.content')}
</pre>
<div className="flex justify-end mt-4">
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={onClose}>
<button
className="px-4 py-1.5 text-sm rounded text-white"
style={{ backgroundColor: '#2563eb' }}
onClick={onClose}
>
{t('dialog.confirm')}
</button>
</div>
+41 -5
View File
@@ -9,7 +9,13 @@ interface ImportDialogProps {
onCancel: () => void;
}
export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel }: ImportDialogProps) {
export function ImportDialog({
open,
systemCount,
userCount,
onSelect,
onCancel,
}: ImportDialogProps) {
const { t } = useTranslation();
return (
@@ -21,10 +27,40 @@ export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel
{userCount > 0 && t('dialog.importUserCount', { count: userCount })}
</p>
<div className="flex flex-col gap-2">
{systemCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('system')}>{t('dialog.importSystem')}</button>}
{userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('user')}>{t('dialog.importUser')}</button>}
{systemCount > 0 && userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('both')}>{t('dialog.importBoth')}</button>}
<button className="px-4 py-2 text-sm rounded border mt-2" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>{t('dialog.cancel')}</button>
{systemCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
onClick={() => onSelect('system')}
>
{t('dialog.importSystem')}
</button>
)}
{userCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
onClick={() => onSelect('user')}
>
{t('dialog.importUser')}
</button>
)}
{systemCount > 0 && userCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
onClick={() => onSelect('both')}
>
{t('dialog.importBoth')}
</button>
)}
<button
className="px-4 py-2 text-sm rounded border mt-2"
style={{ borderColor: 'var(--app-border)' }}
onClick={onCancel}
>
{t('dialog.cancel')}
</button>
</div>
</Modal>
);
+34 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
@@ -10,30 +10,54 @@ interface PathEditDialogProps {
onCancel: () => void;
}
export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel }: PathEditDialogProps) {
export function PathEditDialog({
open,
title,
initialValue,
onConfirm,
onCancel,
}: PathEditDialogProps) {
const { t } = useTranslation();
const [value, setValue] = useState(initialValue);
const prevOpen = useRef(open);
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
useEffect(() => {
if (open && !prevOpen.current) setValue(initialValue);
prevOpen.current = open;
}, [open, initialValue]);
return (
<Modal open={open} onClose={onCancel}>
<h2 className="text-lg font-semibold mb-4">{title}</h2>
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
<input
type="text" autoFocus value={value}
type="text"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onConfirm(value); }}
onKeyDown={(e) => {
if (e.key === 'Enter') onConfirm(value);
}}
className="w-full min-w-[400px] px-3 py-2 rounded border text-sm outline-none"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
style={{
backgroundColor: 'var(--app-list-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
}}
/>
<div className="flex justify-end gap-2 mt-4">
<button className="px-4 py-1.5 text-sm rounded border" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>
<button
className="px-4 py-1.5 text-sm rounded border"
style={{ borderColor: 'var(--app-border)' }}
onClick={onCancel}
>
{t('dialog.cancel')}
</button>
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={() => onConfirm(value)}>
<button
className="px-4 py-1.5 text-sm rounded text-white"
style={{ backgroundColor: '#2563eb' }}
onClick={() => onConfirm(value)}
>
{t('dialog.confirm')}
</button>
</div>
+74 -22
View File
@@ -65,22 +65,39 @@ export function ProfileDialog({ open, onClose }: Props) {
if (!selected || !selectedData) return;
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
useAppStore.getState().replaceBothPaths(
selectedData.sys.map(e => e.path),
selectedData.user.map(e => e.path),
selectedData.sys.map((e) => e.path),
selectedData.user.map((e) => e.path),
);
// 同步 disabled 状态
await invoke('save_disabled_state', {
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
system: selectedData.sys.filter((e) => !e.enabled).map((e) => e.path),
user: selectedData.user.filter((e) => !e.enabled).map((e) => e.path),
});
await useAppStore.getState().savePaths();
onClose();
const result = await useAppStore.getState().savePaths();
if (result.kind === 'success') {
onClose();
} else if (result.kind === 'warning') {
const { ask } = await import('@tauri-apps/plugin-dialog');
const confirmed = await ask(t('status.saveWarningLongPaths'), {
title: t('dialog.backupTitle'),
kind: 'warning',
});
if (confirmed) {
const forceResult = await useAppStore.getState().savePaths(true);
if (forceResult.kind === 'success') {
onClose();
}
}
}
};
const handleDelete = async (name: string) => {
if (!window.confirm(`删除配置文件 "${name}"`)) return;
await invoke('delete_profile', { name });
if (selected === name) { setSelected(null); setSelectedData(null); }
if (selected === name) {
setSelected(null);
setSelectedData(null);
}
refreshProfiles();
};
@@ -95,16 +112,23 @@ export function ProfileDialog({ open, onClose }: Props) {
return (
<Modal open={open} onClose={onClose}>
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
<div
className="flex items-center justify-between px-5 py-3 border-b"
style={{ borderColor: 'var(--app-border)' }}
>
<h2 className="text-base font-semibold">{t('profile.title')}</h2>
<div className="flex gap-2 items-center">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('profile.namePlaceholder')}
className="px-2 py-1 text-sm rounded border outline-none w-44"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
style={{
backgroundColor: 'var(--app-list-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
}}
/>
<button
className="px-3 py-1 text-sm rounded text-white"
@@ -127,11 +151,16 @@ export function ProfileDialog({ open, onClose }: Props) {
<div className="flex flex-1 overflow-hidden">
{/* 左侧:列表 */}
<div className="w-48 border-r overflow-auto p-2" style={{ borderColor: 'var(--app-border)' }}>
<div
className="w-48 border-r overflow-auto p-2"
style={{ borderColor: 'var(--app-border)' }}
>
{profiles.length === 0 ? (
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>{t('profile.noProfiles')}</div>
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>
{t('profile.noProfiles')}
</div>
) : (
profiles.map(p => (
profiles.map((p) => (
<div
key={p.name}
onClick={() => handleLoad(p.name)}
@@ -157,7 +186,9 @@ export function ProfileDialog({ open, onClose }: Props) {
<div>
<div className="flex items-center gap-2 mb-3">
<span className="font-semibold text-sm">{selectedData.name}</span>
<span className="text-xs" style={{ opacity: 0.5 }}>{selectedData.modified}</span>
<span className="text-xs" style={{ opacity: 0.5 }}>
{selectedData.modified}
</span>
</div>
<div className="flex gap-1.5 mb-3">
@@ -171,7 +202,10 @@ export function ProfileDialog({ open, onClose }: Props) {
<button
className="px-3 py-1 text-xs rounded"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
onClick={() => { setRenameOpen(true); setRenameValue(selectedData.name); }}
onClick={() => {
setRenameOpen(true);
setRenameValue(selectedData.name);
}}
>
{t('profile.rename')}
</button>
@@ -189,18 +223,32 @@ export function ProfileDialog({ open, onClose }: Props) {
<input
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onChange={(e) => setRenameValue(e.target.value)}
className="px-2 py-1 text-xs rounded border outline-none"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
style={{
backgroundColor: 'var(--app-list-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
}}
/>
<button className="px-2 py-1 text-xs rounded text-white" style={{ backgroundColor: '#3b82f6' }} onClick={handleRename}>
<button
className="px-2 py-1 text-xs rounded text-white"
style={{ backgroundColor: '#3b82f6' }}
onClick={handleRename}
>
{t('button.save')}
</button>
</div>
)}
<PathSection title={`系统 PATH (${selectedData.sys.length})`} paths={selectedData.sys} />
<PathSection title={`用户 PATH (${selectedData.user.length})`} paths={selectedData.user} />
<PathSection
title={`系统 PATH (${selectedData.sys.length})`}
paths={selectedData.sys}
/>
<PathSection
title={`用户 PATH (${selectedData.user.length})`}
paths={selectedData.user}
/>
</div>
)}
</div>
@@ -214,9 +262,13 @@ function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
const { t } = useTranslation();
return (
<div className="mb-2">
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>{title}</div>
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>
{title}
</div>
{paths.length === 0 ? (
<div className="text-xs" style={{ opacity: 0.4 }}>{t('profile.empty')}</div>
<div className="text-xs" style={{ opacity: 0.4 }}>
{t('profile.empty')}
</div>
) : (
<div className="space-y-0.5 max-h-48 overflow-auto">
{paths.map((e) => (
+61 -12
View File
@@ -28,19 +28,32 @@ export function AppShell() {
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
const [editDialog, setEditDialog] = useState<DialogState['editDialog']>({
open: false, index: -1, value: '', target: TargetType.SYSTEM,
open: false,
index: -1,
value: '',
target: TargetType.SYSTEM,
});
const [newDialog, setNewDialog] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
open: false, system: [], user: [],
open: false,
system: [],
user: [],
});
const [analyzeOpen, setAnalyzeOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const actions = useAppActions(activeTab, {
editDialog, newDialog, helpOpen, importDialog,
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
editDialog,
newDialog,
helpOpen,
importDialog,
setEditDialog,
setNewDialog,
setHelpOpen,
setImportDialog,
setAnalyzeOpen,
setProfilesOpen,
});
const tabConfig: { id: TabId; label: string }[] = [
@@ -50,14 +63,20 @@ export function AppShell() {
];
return (
<div className="flex flex-col h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
<div
className="flex flex-col h-screen"
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
>
<TitleBar />
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
{tabConfig.map((tab) => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setSelectedIndices([]); }}
onClick={() => {
setActiveTab(tab.id);
setSelectedIndices([]);
}}
className={`px-4 py-1.5 text-sm font-medium transition-colors ${activeTab === tab.id ? 'tab-active' : 'opacity-60'}`}
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
>
@@ -96,7 +115,10 @@ export function AppShell() {
<div
className="flex-1 overflow-auto"
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
}}
onDrop={(e) => {
e.preventDefault();
if (activeTab === 'merged') return;
@@ -104,20 +126,47 @@ export function AppShell() {
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
if (entry?.isDirectory) {
const file = e.dataTransfer.files[i] as TauriFile;
if (file.path) useAppStore.getState().addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
if (file.path)
useAppStore
.getState()
.addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
}
}
}}
>
{activeTab === 'merged' ? <MergePreview /> : <PathTable tabId={activeTab as 'system' | 'user'} />}
{activeTab === 'merged' ? (
<MergePreview />
) : (
<PathTable tabId={activeTab as 'system' | 'user'} />
)}
</div>
<StatusBar />
<PathEditDialog open={newDialog} title={t('dialog.newPath')} initialValue="" onConfirm={actions.handleNewConfirm} onCancel={() => setNewDialog(false)} />
<PathEditDialog open={editDialog.open} title={t('dialog.editPath')} initialValue={editDialog.value} onConfirm={actions.handleEditConfirm} onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} />
<PathEditDialog
open={newDialog}
title={t('dialog.newPath')}
initialValue=""
onConfirm={actions.handleNewConfirm}
onCancel={() => setNewDialog(false)}
/>
<PathEditDialog
open={editDialog.open}
title={t('dialog.editPath')}
initialValue={editDialog.value}
onConfirm={actions.handleEditConfirm}
onCancel={() =>
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })
}
/>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
<ImportDialog open={importDialog.open} systemCount={importDialog.system.length} userCount={importDialog.user.length} onSelect={actions.handleImportSelect} onCancel={() => setImportDialog({ open: false, system: [], user: [] })} />
<ImportDialog
open={importDialog.open}
systemCount={importDialog.system.length}
userCount={importDialog.user.length}
onSelect={actions.handleImportSelect}
onCancel={() => setImportDialog({ open: false, system: [], user: [] })}
/>
<AnalyzeDialog open={analyzeOpen} onClose={() => setAnalyzeOpen(false)} />
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
</div>
+11 -3
View File
@@ -1,7 +1,12 @@
import { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; }
interface State { hasError: boolean; error: string; }
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: string;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: '' };
@@ -18,7 +23,10 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
<div
className="flex items-center justify-center h-screen"
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
>
<div className="text-center space-y-4">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm opacity-70">{this.state.error}</p>
+1 -3
View File
@@ -11,9 +11,7 @@ export function TitleBar() {
className="flex items-center justify-between px-4 py-2 border-b select-none"
style={{ borderColor: 'var(--app-border)' }}
>
<h1 className="text-lg font-semibold">
{isAdmin ? t('app.name') : t('app.nameReadonly')}
</h1>
<h1 className="text-lg font-semibold">{isAdmin ? t('app.name') : t('app.nameReadonly')}</h1>
<span className="text-sm opacity-60">v{version}</span>
</header>
);
+56 -40
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { useTranslation } from 'react-i18next';
import type { PathEntry } from '@/core/path-entry';
import { useVirtualizer } from '@tanstack/react-virtual';
export function MergePreview() {
const sysPaths = useAppStore((s) => s.sysPaths);
@@ -33,47 +34,62 @@ export function MergePreview() {
return merged.filter((r) => r.path.toLowerCase().includes(q));
}, [sysPaths, userPaths, searchQuery, t]);
return (
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead>
<tr
className="sticky top-0 z-10 text-left text-xs uppercase"
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<th className="w-10 px-2 py-1">#</th>
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
</tr>
</thead>
<tbody>
{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;
const parentRef = useRef<HTMLDivElement>(null);
return (
<tr
key={`${source}-${displayIndex}`}
style={{
backgroundColor:
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
color: 'var(--app-fg)',
}}
const rowVirtualizer = useVirtualizer({
count: allPaths.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // 预估行高 28px
initialRect: { width: 800, height: 600 },
});
return (
<div ref={parentRef} className="flex-1 overflow-auto relative">
<div
className="sticky top-0 z-10 flex text-left text-xs uppercase"
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<div className="w-10 px-2 py-1">#</div>
<div className="px-2 py-1 flex-1">{t('dialog.pathLabel')}</div>
<div className="w-16 px-2 py-1">{t('merge.source')}</div>
</div>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowIdx = virtualRow.index;
const { path, enabled, source, displayIndex } = allPaths[rowIdx];
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
const textDecoration = enabled ? 'none' : 'line-through';
const opacity = enabled ? 1 : 0.6;
return (
<div
key={`${source}-${displayIndex}`}
className="flex items-center absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
backgroundColor: rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
color: 'var(--app-fg)',
}}
>
<div className="w-10 px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</div>
<div
className="px-2 py-0.5 text-sm flex-1 truncate"
style={{ color: textColor, textDecoration, opacity }}
>
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</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>
</tr>
);
})}
</tbody>
</table>
{path}
</div>
<div className="w-16 px-2 py-0.5 text-xs opacity-60">{source}</div>
</div>
);
})}
</div>
</div>
);
}
+88 -68
View File
@@ -1,9 +1,10 @@
import { useMemo, useCallback } from 'react';
import { useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '@/store/app-store';
import { TargetType } from '@/core/undo-redo';
import { usePathValidation } from '@/hooks/use-path-validation';
import type { ValidationState } from '@/hooks/use-path-validation';
import { useVirtualizer } from '@tanstack/react-virtual';
interface PathTableProps {
tabId: 'system' | 'user';
@@ -36,7 +37,8 @@ export function PathTable({ tabId }: PathTableProps) {
const result: PathRow[] = [];
for (let i = 0; i < paths.length; i++) {
const p = paths[i];
if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled });
if (p.path.toLowerCase().includes(q))
result.push({ path: p.path, index: i, enabled: p.enabled });
}
return result;
}, [paths, searchQuery]);
@@ -80,76 +82,94 @@ export function PathTable({ tabId }: PathTableProps) {
[isActive, paths],
);
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // 预估行高 28px
initialRect: { width: 800, height: 600 },
});
return (
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead>
<tr
className="sticky top-0 z-10 text-left text-xs uppercase"
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<th className="w-8 px-2 py-1">#</th>
<th className="w-6 px-1 py-1"></th>
<th className="px-2 py-1">{t('table.path')}</th>
</tr>
</thead>
<tbody>
{filtered.map(({ path, index, enabled }, rowIdx) => {
const v = validations[rowIdx];
const isSelected = selectedIndices.includes(index);
let textColor = 'var(--app-fg)';
if (v.state === 'invalid') textColor = '#dc3545';
else if (v.isDuplicate) textColor = '#fd7e14';
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
<div ref={parentRef} className="flex-1 overflow-auto relative">
<div
className="sticky top-0 z-10 flex text-left text-xs uppercase"
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<div className="w-8 px-2 py-1">#</div>
<div className="w-6 px-1 py-1"></div>
<div className="px-2 py-1 flex-1">{t('table.path')}</div>
</div>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowIdx = virtualRow.index;
const { path, index, enabled } = filtered[rowIdx];
const v = validations[rowIdx];
const isSelected = selectedIndices.includes(index);
let textColor = 'var(--app-fg)';
if (v.state === 'invalid') textColor = '#dc3545';
else if (v.isDuplicate) textColor = '#fd7e14';
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;
}
let textDecoration = 'none';
let opacity = 1;
if (!enabled) {
textColor = '#6b7280';
textDecoration = 'line-through';
opacity = 0.6;
}
return (
<tr
key={index}
onClick={(e) => handleClick(index, e)}
onDoubleClick={() => handleDoubleClick(index)}
className="cursor-pointer select-none"
style={{
backgroundColor: isSelected
? 'var(--app-select-row)'
: rowIdx % 2 === 0
? 'var(--app-list-bg)'
: 'var(--app-list-alt)',
}}
return (
<div
key={virtualRow.key}
onClick={(e) => handleClick(index, e)}
onDoubleClick={() => handleDoubleClick(index)}
className="cursor-pointer select-none flex items-center absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
backgroundColor: isSelected
? 'var(--app-select-row)'
: rowIdx % 2 === 0
? 'var(--app-list-bg)'
: 'var(--app-list-alt)',
}}
>
<div
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}
</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
className="px-2 py-0.5 text-sm truncate max-w-2xl"
style={{ color: textColor, textDecoration, opacity }}
title={expandedCache.get(path) || undefined}
>
{path}
</td>
</tr>
);
})}
</tbody>
</table>
{index + 1}
</div>
<div className="w-6 px-1 py-0.5 flex items-center">
<input
type="checkbox"
checked={enabled}
onChange={() => {
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
useAppStore.getState().togglePath(index, target);
}}
className="cursor-pointer"
/>
</div>
<div
className="px-2 py-0.5 text-sm truncate flex-1"
style={{ color: textColor, textDecoration, opacity }}
title={expandedCache.get(path) || undefined}
>
{path}
</div>
</div>
);
})}
</div>
</div>
);
}
+1 -6
View File
@@ -36,12 +36,7 @@ export function ToolBar(props: ToolBarProps) {
<SearchInput />
<div className="flex-1" />
<UndoRedoButtons />
<button
className={btnClass}
style={btnStyle}
disabled={!isAdmin}
onClick={props.onImport}
>
<button className={btnClass} style={btnStyle} disabled={!isAdmin} onClick={props.onImport}>
{t('button.import')}
</button>
<button className={btnClass} style={btnStyle} onClick={props.onExport}>
+3 -1
View File
@@ -9,7 +9,9 @@ interface ModalProps {
export function Modal({ open, onClose, children }: ModalProps) {
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
+2 -1
View File
@@ -1,4 +1,5 @@
export const btnClass = 'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
export const btnClass =
'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
export const btnStyle: React.CSSProperties = {
backgroundColor: 'var(--app-bg)',
+9 -8
View File
@@ -28,8 +28,8 @@ export function exportToJson(data: ExportData): string {
const obj = {
version,
timestamp: new Date().toISOString(),
system: data.system.map(e => ({ path: e.path, enabled: e.enabled })),
user: data.user.map(e => ({ path: e.path, enabled: e.enabled })),
system: data.system.map((e) => ({ path: e.path, enabled: e.enabled })),
user: data.user.map((e) => ({ path: e.path, enabled: e.enabled })),
};
return JSON.stringify(obj, null, 2);
}
@@ -179,10 +179,14 @@ export function importFromJson(content: string): ImportResult {
};
if (Array.isArray(obj.system)) {
result.system = obj.system.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
result.system = obj.system
.map(parseEntry)
.filter((e): e is { path: string; enabled: boolean } => e !== null);
}
if (Array.isArray(obj.user)) {
result.user = obj.user.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
result.user = obj.user
.map(parseEntry)
.filter((e): e is { path: string; enabled: boolean } => e !== null);
}
return result;
@@ -210,10 +214,7 @@ export function importFromTxt(content: string): PathEntry[] {
// ── 自动检测导入 ──
export function importFromContent(
content: string,
filepath: string,
): ImportResult {
export function importFromContent(content: string, filepath: string): ImportResult {
const lower = filepath.toLowerCase();
if (lower.endsWith('.csv')) {
return importFromCsv(content);
+5 -1
View File
@@ -21,7 +21,11 @@ export function analyzePaths(
const lower = entry.path.toLowerCase();
const isDuplicate = seen.has(lower);
seen.add(lower);
result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') });
result.push({
isValid: validateFn(entry.path),
isDuplicate,
isEnvVar: entry.path.includes('%'),
});
}
return result;
+31 -7
View File
@@ -5,7 +5,16 @@
import type { PathEntry } from './path-entry';
export const OperationType = {
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8, IMPORT_BOTH: 9,
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;
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
@@ -47,7 +56,10 @@ export class UndoRedoManager {
this.current = this.records.length - 1;
}
undo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
undo(
sysPaths: readonly PathEntry[],
userPaths: readonly PathEntry[],
): [PathEntry[], PathEntry[]] | null {
if (this.current < 0) return null;
const rec = this.records[this.current];
@@ -103,7 +115,10 @@ export class UndoRedoManager {
return [sys, user];
}
redo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
redo(
sysPaths: readonly PathEntry[],
userPaths: readonly PathEntry[],
): [PathEntry[], PathEntry[]] | null {
if (this.current >= this.records.length - 1) return null;
this.current++;
@@ -159,8 +174,17 @@ export class UndoRedoManager {
return [sys, user];
}
canUndo(): boolean { return this.current >= 0; }
canRedo(): boolean { return this.current < this.records.length - 1; }
clear(): void { this.records = []; this.current = -1; }
get historyLength(): number { return this.records.length; }
canUndo(): boolean {
return this.current >= 0;
}
canRedo(): boolean {
return this.current < this.records.length - 1;
}
clear(): void {
this.records = [];
this.current = -1;
}
get historyLength(): number {
return this.records.length;
}
}
+79 -40
View File
@@ -3,7 +3,12 @@ import { useAppStore } from '@/store/app-store';
import { TargetType } from '@/core/undo-redo';
import { open } from '@tauri-apps/plugin-dialog';
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 { useKeyboard } from './use-keyboard';
@@ -38,9 +43,10 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const idx = useAppStore.getState().selectedIndices[0];
if (idx === undefined) return;
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
const list = target === TargetType.SYSTEM
? useAppStore.getState().sysPaths
: useAppStore.getState().userPaths;
const list =
target === TargetType.SYSTEM
? useAppStore.getState().sysPaths
: useAppStore.getState().userPaths;
const entry = list[idx];
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
}, [activeTab, setEditDialog]);
@@ -71,14 +77,9 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
}, [getCurrentTarget]);
const handleClean = useCallback(() => {
const removed = useAppStore.getState().cleanPaths(
getCurrentTarget(),
is_valid_path_format,
);
const removed = useAppStore.getState().cleanPaths(getCurrentTarget(), is_valid_path_format);
if (removed.length > 0) {
useAppStore.getState().setStatusMessage(
i18n.t('status.deleted', { count: removed.length }),
);
useAppStore.getState().setStatusMessage(i18n.t('status.deleted', { count: removed.length }));
}
}, [getCurrentTarget]);
@@ -95,9 +96,15 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
if (result.system.length > 0 && result.user.length > 0) {
setImportDialog({ open: true, system: result.system, user: result.user });
} else if (result.system.length > 0) {
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system.map(e => e.path));
useAppStore.getState().replacePaths(
TargetType.SYSTEM,
result.system.map((e) => e.path),
);
} else if (result.user.length > 0) {
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
useAppStore.getState().replacePaths(
TargetType.USER,
result.user.map((e) => e.path),
);
}
}, [setImportDialog]);
@@ -118,11 +125,14 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
}, []);
const handleSave = useCallback(async () => {
const saved = await useAppStore.getState().savePaths();
if (!saved && !useAppStore.getState().isSaving) {
const result = await useAppStore.getState().savePaths();
if (result.kind === 'warning') {
// 长度超限,需要用户确认
const { ask } = await import('@tauri-apps/plugin-dialog');
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' });
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), {
title: i18n.t('dialog.backupTitle'),
kind: 'warning',
});
if (confirmed) {
await useAppStore.getState().savePaths(true);
}
@@ -156,33 +166,62 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
// ── 弹窗确认 ──
const handleNewConfirm = useCallback((value: string) => {
setNewDialog(false);
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
}, [getCurrentTarget, setNewDialog]);
const handleNewConfirm = useCallback(
(value: string) => {
setNewDialog(false);
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
},
[getCurrentTarget, setNewDialog],
);
const handleEditConfirm = useCallback((value: string) => {
const d = dialogs.editDialog;
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
}, [dialogs.editDialog, setEditDialog]);
const handleEditConfirm = useCallback(
(value: string) => {
const d = dialogs.editDialog;
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
},
[dialogs.editDialog, setEditDialog],
);
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
const { system, user } = dialogs.importDialog;
const flat = flattenImportResult({ system, user }, target);
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
useAppStore.getState().replaceBothPaths(flat.system.map(e => e.path), flat.user.map(e => e.path));
} else {
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
}
setImportDialog({ open: false, system: [], user: [] });
}, [dialogs.importDialog, setImportDialog]);
const handleImportSelect = useCallback(
(target: 'system' | 'user' | 'both') => {
const { system, user } = dialogs.importDialog;
const flat = flattenImportResult({ system, user }, target);
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
useAppStore.getState().replaceBothPaths(
flat.system.map((e) => e.path),
flat.user.map((e) => e.path),
);
} else {
if (flat.system.length > 0)
useAppStore.getState().replacePaths(
TargetType.SYSTEM,
flat.system.map((e) => e.path),
);
if (flat.user.length > 0)
useAppStore.getState().replacePaths(
TargetType.USER,
flat.user.map((e) => e.path),
);
}
setImportDialog({ open: false, system: [], user: [] });
},
[dialogs.importDialog, setImportDialog],
);
return {
handleNew, handleEdit, handleBrowse, handleDelete,
handleMoveUp, handleMoveDown, handleClean,
handleImport, handleExport, handleSave,
handleNewConfirm, handleEditConfirm, handleImportSelect,
handleNew,
handleEdit,
handleBrowse,
handleDelete,
handleMoveUp,
handleMoveDown,
handleClean,
handleImport,
handleExport,
handleSave,
handleNewConfirm,
handleEditConfirm,
handleImportSelect,
};
}
+18 -24
View File
@@ -60,17 +60,15 @@ export function usePathValidation(paths: readonly PathEntry[]) {
const batch = toValidate.slice(0, 20);
Promise.all(
batch.map(
async (p): Promise<[string, ValidationState]> => {
try {
if (p.path.includes('%')) return [p.path, 'valid'];
const valid: boolean = await invoke('validate_path', { path: p.path });
return [p.path, valid ? 'valid' : 'invalid'];
} catch {
return [p.path, 'unknown'];
}
},
),
batch.map(async (p): Promise<[string, ValidationState]> => {
try {
if (p.path.includes('%')) return [p.path, 'valid'];
const valid: boolean = await invoke('validate_path', { path: p.path });
return [p.path, valid ? 'valid' : 'invalid'];
} catch {
return [p.path, 'unknown'];
}
}),
).then((results) => {
if (cancelled) return;
for (const [p] of results) validatedRef.current.add(p);
@@ -89,23 +87,19 @@ export function usePathValidation(paths: readonly PathEntry[]) {
// 异步展开环境变量(setState 在 .then() 回调中)
useEffect(() => {
let cancelled = false;
const toExpand = paths.filter(
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
);
const toExpand = paths.filter((p) => p.path.includes('%') && !expandedRef.current.has(p.path));
if (toExpand.length === 0) return;
const batch = toExpand.slice(0, 20);
Promise.all(
batch.map(
async (p): Promise<[string, string]> => {
try {
const expanded: string = await invoke('expand_env_vars', { path: p.path });
return [p.path, expanded !== p.path ? expanded : ''];
} catch {
return [p.path, ''];
}
},
),
batch.map(async (p): Promise<[string, string]> => {
try {
const expanded: string = await invoke('expand_env_vars', { path: p.path });
return [p.path, expanded !== p.path ? expanded : ''];
} catch {
return [p.path, ''];
}
}),
).then((results) => {
if (cancelled) return;
for (const [p] of results) expandedRef.current.add(p);
+388 -297
View File
@@ -8,6 +8,13 @@ import appConfig from '@/config/default.json';
export type TabId = 'system' | 'user' | 'merged';
export type SaveResult =
| { kind: 'success' }
| { kind: 'warning'; reason: 'lengthExceeded' }
| { kind: 'failure'; message: string }
| { kind: 'partial'; message: string }
| { kind: 'blocked' };
interface AppState {
sysPaths: PathEntry[];
userPaths: PathEntry[];
@@ -45,13 +52,14 @@ interface AppState {
redo: () => void;
loadPaths: () => Promise<void>;
savePaths: (force?: boolean) => Promise<boolean>;
savePaths: (force?: boolean) => Promise<SaveResult>;
initialize: () => Promise<void>;
}
function arraysEqual(a: readonly PathEntry[], b: readonly PathEntry[]): boolean {
return a.length === b.length && a.every((v, i) => v.path === b[i].path && v.enabled === b[i].enabled);
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) => {
@@ -62,322 +70,405 @@ export const useAppStore = create<AppState>((set, get) => {
return {
sysPaths: [],
userPaths: [],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
_savedSys: [],
_savedUser: [],
userPaths: [],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
_savedSys: [],
_savedUser: [],
activeTab: 'system',
searchQuery: '',
selectedIndices: [],
isAdmin: false,
statusMessage: '',
isModified: false,
isLoading: true,
isSaving: false,
activeTab: 'system',
searchQuery: '',
selectedIndices: [],
isAdmin: false,
statusMessage: '',
isModified: false,
isLoading: true,
isSaving: false,
setActiveTab: (tab) => set({ activeTab: tab }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
setStatusMessage: (msg) => set({ statusMessage: msg }),
setActiveTab: (tab) => set({ activeTab: tab }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
setStatusMessage: (msg) => set({ statusMessage: msg }),
addPath: (path, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const entry: PathEntry = { path, enabled: true };
const newList = [...list, entry];
state.undoRedo.push({
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
oldPaths: [], newPaths: [entry],
});
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
markDirty();
},
editPath: (index, newPath, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const oldEntry = list[index];
if (!oldEntry) return;
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
state.undoRedo.push({
type: OperationType.EDIT, 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();
},
deletePaths: (indices, target) => {
if (indices.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const sortedDesc = [...indices].sort((a, b) => b - a);
const sortedAsc = [...indices].sort((a, b) => a - b);
const oldPaths = sortedAsc.map((i) => list[i]);
state.undoRedo.push({
type: OperationType.DELETE, target,
index: sortedAsc[0], count: sortedAsc.length,
oldPaths, newPaths: [],
indices: sortedAsc,
});
const toRemove = new Set(sortedDesc);
const newList = list.filter((_, i) => !toRemove.has(i));
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
else set({ userPaths: newList, selectedIndices: [] });
markDirty();
},
moveUp: (index, target) => {
if (index <= 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
state.undoRedo.push({
type: OperationType.MOVE_UP, target, index, count: 1, oldPaths: [], newPaths: [],
});
const newList = [...list];
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
else set({ userPaths: newList, selectedIndices: [index - 1] });
markDirty();
},
moveDown: (index, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (index >= list.length - 1) return;
state.undoRedo.push({
type: OperationType.MOVE_DOWN, target, index, count: 1, oldPaths: [], newPaths: [],
});
const newList = [...list];
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
else set({ userPaths: newList, selectedIndices: [index + 1] });
markDirty();
},
cleanPaths: (target, validateFn) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const [kept, removed] = pathClean(list, validateFn);
if (removed.length > 0) {
addPath: (path, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const entry: PathEntry = { path, enabled: true };
const newList = [...list, entry];
state.undoRedo.push({
type: OperationType.CLEAN, target, index: 0, count: removed.length,
oldPaths: [...list], newPaths: kept,
type: OperationType.ADD,
target,
index: newList.length - 1,
count: 1,
oldPaths: [],
newPaths: [entry],
});
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
else set({ userPaths: kept, selectedIndices: [] });
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
markDirty();
}
},
return removed.map(e => e.path);
},
replacePaths: (target, newPaths) => {
if (newPaths.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const entries: PathEntry[] = newPaths.map(p => ({ path: p, enabled: true }));
state.undoRedo.push({
type: OperationType.IMPORT, target, index: 0, count: entries.length,
oldPaths: [...list], newPaths: [...entries],
});
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], 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();
},
clearPaths: (target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (list.length === 0) return;
state.undoRedo.push({
type: OperationType.CLEAR, target, index: 0, count: list.length,
oldPaths: [...list], newPaths: [],
});
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
else set({ userPaths: [] });
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((e) => console.warn('保存禁用状态失败:', e));
},
undo: () => {
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.undo(sysPaths, userPaths);
if (result) {
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
editPath: (index, newPath, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const oldEntry = list[index];
if (!oldEntry) return;
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
state.undoRedo.push({
type: OperationType.EDIT,
target,
index,
count: 1,
oldPaths: [oldEntry],
newPaths: [newEntry],
});
// 同步持久化 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((e) => console.warn('保存禁用状态失败:', e));
}
},
const newList = [...list];
newList[index] = newEntry;
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
markDirty();
},
redo: () => {
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.redo(sysPaths, userPaths);
if (result) {
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
deletePaths: (indices, target) => {
if (indices.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const sortedDesc = [...indices].sort((a, b) => b - a);
const sortedAsc = [...indices].sort((a, b) => a - b);
const oldPaths = sortedAsc.map((i) => list[i]);
state.undoRedo.push({
type: OperationType.DELETE,
target,
index: sortedAsc[0],
count: sortedAsc.length,
oldPaths,
newPaths: [],
indices: sortedAsc,
});
// 同步持久化 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((e) => console.warn('保存禁用状态失败:', e));
}
},
loadPaths: async () => {
try {
set({ isLoading: true });
const [sysArr, userArr] = await Promise.all([
invoke<string[]>('load_system_paths'),
invoke<string[]>('load_user_paths'),
]);
const toRemove = new Set(sortedDesc);
const newList = list.filter((_, i) => !toRemove.has(i));
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
else set({ userPaths: newList, selectedIndices: [] });
markDirty();
},
// 加载禁用状态(文件不存在时返回空)
let sysDisabled: string[] = [];
let usrDisabled: string[] = [];
try {
const result = await invoke<[string[], string[]]>('load_disabled_state');
sysDisabled = result[0];
usrDisabled = result[1];
} catch {
// 文件不存在或损坏,忽略
moveUp: (index, target) => {
if (index <= 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
state.undoRedo.push({
type: OperationType.MOVE_UP,
target,
index,
count: 1,
oldPaths: [],
newPaths: [],
});
const newList = [...list];
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
else set({ userPaths: newList, selectedIndices: [index - 1] });
markDirty();
},
moveDown: (index, target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (index >= list.length - 1) return;
state.undoRedo.push({
type: OperationType.MOVE_DOWN,
target,
index,
count: 1,
oldPaths: [],
newPaths: [],
});
const newList = [...list];
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
else set({ userPaths: newList, selectedIndices: [index + 1] });
markDirty();
},
cleanPaths: (target, validateFn) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const [kept, removed] = pathClean(list, validateFn);
if (removed.length > 0) {
state.undoRedo.push({
type: OperationType.CLEAN,
target,
index: 0,
count: removed.length,
oldPaths: [...list],
newPaths: kept,
});
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
else set({ userPaths: kept, selectedIndices: [] });
markDirty();
}
const sysSet = new Set(sysDisabled);
const usrSet = new Set(usrDisabled);
return removed.map((e) => e.path);
},
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysSet.has(p) }));
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrSet.has(p) }));
replacePaths: (target, newPaths) => {
if (newPaths.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const entries: PathEntry[] = newPaths.map((p) => ({ path: p, enabled: true }));
set({
sysPaths: sysEntries, userPaths: usrEntries,
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
isLoading: false, isModified: false,
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
state.undoRedo.push({
type: OperationType.IMPORT,
target,
index: 0,
count: entries.length,
oldPaths: [...list],
newPaths: [...entries],
});
} catch (e) {
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
}
},
savePaths: async (force?: boolean) => {
const state = get();
if (state.isSaving) return false;
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
else set({ userPaths: [...entries], selectedIndices: [] });
markDirty();
},
// 只保存 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 userJoined = userPaths.join(';');
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();
},
// 长度检查:非强制模式下返回警告,由 UI 层确认
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) {
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
return false;
}
clearPaths: (target) => {
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (list.length === 0) return;
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
let backupFailed = false;
await invoke('backup_registry', { customDir: null })
.catch(() => { backupFailed = true; });
state.undoRedo.push({
type: OperationType.CLEAR,
target,
index: 0,
count: list.length,
oldPaths: [...list],
newPaths: [],
});
const [sysResult, userResult] = await Promise.allSettled([
invoke('save_system_paths', { paths: sysPaths }),
invoke('save_user_paths', { paths: userPaths }),
]);
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
else set({ userPaths: [] });
markDirty();
},
const sysOk = sysResult.status === 'fulfilled';
const userOk = userResult.status === 'fulfilled';
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 };
if (sysOk && userOk) {
invoke('broadcast_env_change').catch(() => {});
const savedSys = [...state.sysPaths], savedUser = [...state.userPaths];
set({ isModified: false, isSaving: false,
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
_savedSys: savedSys, _savedUser: savedUser });
return true;
} else {
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 = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`;
set({ isSaving: false, statusMessage: msg });
return false;
}
},
state.undoRedo.push({
type: OperationType.TOGGLE,
target,
index,
count: 1,
oldPaths: [oldEntry],
newPaths: [newEntry],
});
initialize: async () => {
try {
const isAdmin: boolean = await invoke('check_admin');
set({ isAdmin });
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
} catch {
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
}
await get().loadPaths();
},
};});
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((e) =>
console.warn('保存禁用状态失败:', e),
);
},
undo: () => {
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.undo(sysPaths, userPaths);
if (result) {
set({
sysPaths: result[0],
userPaths: result[1],
selectedIndices: [],
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
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((e) => console.warn('保存禁用状态失败:', e));
}
},
redo: () => {
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.redo(sysPaths, userPaths);
if (result) {
set({
sysPaths: result[0],
userPaths: result[1],
selectedIndices: [],
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
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((e) => console.warn('保存禁用状态失败:', e));
}
},
loadPaths: async () => {
try {
set({ isLoading: true });
const [sysArr, userArr] = await Promise.all([
invoke<string[]>('load_system_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({
sysPaths: sysEntries,
userPaths: usrEntries,
_savedSys: [...sysEntries],
_savedUser: [...usrEntries],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
isLoading: false,
isModified: false,
statusMessage: i18n.t('status.loaded', {
sysCount: sysArr.length,
userCount: userArr.length,
}),
});
} catch (e) {
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
}
},
savePaths: async (force?: boolean) => {
const state = get();
if (state.isSaving) return { kind: 'blocked' };
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
// 只保存 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 userJoined = userPaths.join(';');
// 长度检查:非强制模式下返回警告,由 UI 层确认
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
if (
!force &&
(sysJoined.length > maxSystemLength ||
userJoined.length > maxUserLength ||
(sysJoined + userJoined).length > maxCombinedLength)
) {
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
return { kind: 'warning', reason: 'lengthExceeded' };
}
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
let backupFailed = false;
await invoke('backup_registry', { customDir: null }).catch(() => {
backupFailed = true;
});
const origSys = state._savedSys.filter((e) => e.enabled).map((e) => e.path);
const origUser = state._savedUser.filter((e) => e.enabled).map((e) => e.path);
const [sysResult, userResult] = await Promise.allSettled([
invoke('save_system_paths', { paths: sysPaths, original: origSys }),
invoke('save_user_paths', { paths: userPaths, original: origUser }),
]);
const sysOk = sysResult.status === 'fulfilled';
const userOk = userResult.status === 'fulfilled';
if (sysOk && userOk) {
invoke('broadcast_env_change').catch(() => {});
const savedSys = [...state.sysPaths],
savedUser = [...state.userPaths];
set({
isModified: false,
isSaving: false,
statusMessage: backupFailed
? i18n.t('status.saved_without_backup')
: i18n.t('status.saved'),
_savedSys: savedSys,
_savedUser: savedUser,
});
return { kind: 'success' };
} else {
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 = sysOk
? `用户 PATH 保存失败: ${usrErr}`
: userOk
? `系统 PATH 保存失败: ${sysErr}`
: `保存失败: ${parts.join('; ')}`;
if (sysOk || userOk) {
// partial success
set({ isSaving: false });
await get().loadPaths(); // reload to avoid state drift
set({ statusMessage: msg }); // restore the error message overwritten by loadPaths
return { kind: 'partial', message: msg };
} else {
set({ isSaving: false, statusMessage: msg });
return { kind: 'failure', message: msg };
}
}
},
initialize: async () => {
try {
const isAdmin: boolean = await invoke('check_admin');
set({ isAdmin });
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
} catch {
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
}
await get().loadPaths();
},
};
});
+1 -3
View File
@@ -26,9 +26,7 @@ vi.mock('@/i18n', () => ({
describe('AnalyzeDialog', () => {
it('渲染冲突检测和工具清单标签页,不崩溃', () => {
const { container } = render(
<AnalyzeDialog open={true} onClose={() => {}} />,
);
const { container } = render(<AnalyzeDialog open={true} onClose={() => {}} />);
const text = container.textContent || '';
expect(text).toContain('analyze.conflicts');
expect(text).toContain('analyze.tools');
+52 -40
View File
@@ -7,16 +7,19 @@ vi.mock('@tauri-apps/api/core', () => ({
// Mock i18n
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'status.loaded') return `已加载 ${opts?.sysCount} 条系统 PATH${opts?.userCount} 条用户 PATH`;
if (key === 'status.error') return '加载失败';
if (key === 'status.saving') return '正在保存...';
if (key === 'status.saved') return '保存成功';
if (key === 'status.warning_backup') return '备份失败,但保存继续';
if (key === 'status.readonly') return '只读模式';
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
return key;
}) },
default: {
t: vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'status.loaded')
return `已加载 ${opts?.sysCount} 条系统 PATH${opts?.userCount} 条用户 PATH`;
if (key === 'status.error') return '加载失败';
if (key === 'status.saving') return '正在保存...';
if (key === 'status.saved') return '保存成功';
if (key === 'status.warning_backup') return '备份失败,但保存继续';
if (key === 'status.readonly') return '只读模式';
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
return key;
}),
},
}));
import type { PathEntry } from '../../src/core/path-entry';
@@ -56,7 +59,7 @@ describe('app-store CRUD', () => {
it('addPath 追加到 sysPaths', () => {
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
const s = useAppStore.getState();
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\test']);
expect(s.sysPaths.map((e) => e.path)).toEqual(['C:\\test']);
expect(s.isModified).toBe(true);
expect(s.undoRedo.historyLength).toBe(1);
});
@@ -64,7 +67,7 @@ describe('app-store CRUD', () => {
it('addPath 追加到 userPaths', () => {
useAppStore.getState().addPath('D:\\user', TargetType.USER);
const s = useAppStore.getState();
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\user']);
expect(s.userPaths.map((e) => e.path)).toEqual(['D:\\user']);
expect(s.sysPaths).toEqual([]);
});
@@ -72,7 +75,7 @@ describe('app-store CRUD', () => {
const store = useAppStore.getState();
store.addPath('C:\\old', TargetType.SYSTEM);
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\new']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\new']);
});
it('editPath 越界 index 无崩溃', () => {
@@ -87,7 +90,7 @@ describe('app-store CRUD', () => {
store.addPath('B', TargetType.SYSTEM);
store.addPath('C', TargetType.SYSTEM);
store.deletePaths([1], TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A', 'C']);
expect(useAppStore.getState().selectedIndices).toEqual([]);
});
@@ -98,7 +101,7 @@ describe('app-store CRUD', () => {
store.addPath('C', TargetType.USER);
store.addPath('D', TargetType.USER);
store.deletePaths([1, 3], TargetType.USER);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A', 'C']);
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['A', 'C']);
});
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
@@ -108,16 +111,16 @@ describe('app-store CRUD', () => {
store.addPath('C', TargetType.SYSTEM);
store.addPath('D', TargetType.SYSTEM);
store.deletePaths([1, 3], TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A', 'C']);
useAppStore.getState().undo();
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'B', 'C', 'D']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A', 'B', 'C', 'D']);
});
it('moveUp index=0 无操作', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.SYSTEM);
store.moveUp(0, TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A']);
});
it('moveUp 正常交换位置', () => {
@@ -125,7 +128,7 @@ describe('app-store CRUD', () => {
store.addPath('A', TargetType.SYSTEM);
store.addPath('B', TargetType.SYSTEM);
store.moveUp(1, TargetType.SYSTEM);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['B', 'A']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['B', 'A']);
expect(useAppStore.getState().selectedIndices).toEqual([0]);
});
@@ -133,7 +136,7 @@ describe('app-store CRUD', () => {
const store = useAppStore.getState();
store.addPath('A', TargetType.USER);
store.moveDown(0, TargetType.USER);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A']);
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['A']);
});
it('cleanPaths 移除无效路径并返回 removed', () => {
@@ -143,7 +146,7 @@ describe('app-store CRUD', () => {
// is_valid_path_format 拒绝全标点路径
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
expect(removed).toEqual([':::invalid:::']);
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\valid']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\valid']);
});
it('replacePaths 整体替换列表', () => {
@@ -151,7 +154,7 @@ describe('app-store CRUD', () => {
store.addPath('old1', TargetType.USER);
store.addPath('old2', TargetType.USER);
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['new1', 'new2', 'new3']);
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['new1', 'new2', 'new3']);
});
it('clearPaths 清空列表', () => {
@@ -187,7 +190,7 @@ describe('undo/redo', () => {
store.addPath('test', TargetType.SYSTEM);
store.undo();
store.redo();
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['test']);
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['test']);
});
it('undo/redo 正确更新 isModified', () => {
@@ -214,8 +217,8 @@ describe('loadPaths', () => {
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
await useAppStore.getState().loadPaths();
const s = useAppStore.getState();
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\usr1']);
expect(s.sysPaths.map((e) => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
expect(s.userPaths.map((e) => e.path)).toEqual(['D:\\usr1']);
expect(s.isLoading).toBe(false);
expect(s.isModified).toBe(false);
});
@@ -239,19 +242,26 @@ describe('savePaths', () => {
it('保存成功', async () => {
mockedInvoke.mockResolvedValue(undefined);
await useAppStore.getState().savePaths();
const result = await useAppStore.getState().savePaths();
expect(result).toEqual({ kind: 'success' });
const s = useAppStore.getState();
expect(s.isSaving).toBe(false);
expect(s.isModified).toBe(false);
expect(s.statusMessage).toBe('保存成功');
});
it('部分失败时报告具体 hive', async () => {
it('部分失败时报告具体 hive 并回读', async () => {
mockedInvoke
.mockResolvedValueOnce(undefined) // backup_registry
.mockResolvedValueOnce(undefined) // save_system_paths
.mockRejectedValueOnce('权限不足'); // save_user_paths
await useAppStore.getState().savePaths();
.mockResolvedValueOnce(undefined) // backup_registry
.mockResolvedValueOnce(undefined) // save_system_paths
.mockRejectedValueOnce('权限不足') // save_user_paths
// 以下为 partial 触发的 loadPaths 调用
.mockResolvedValueOnce(['A']) // load_system_paths
.mockResolvedValueOnce(['B']) // load_user_paths
.mockResolvedValueOnce([[], []]); // load_disabled_state
const result = await useAppStore.getState().savePaths();
expect(result.kind).toBe('partial');
const s = useAppStore.getState();
expect(s.isSaving).toBe(false);
expect(s.statusMessage).toContain('用户 PATH 保存失败');
@@ -259,7 +269,9 @@ describe('savePaths', () => {
it('isSaving 守卫:并发第二次调用直接返回', async () => {
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);
@@ -268,8 +280,8 @@ describe('savePaths', () => {
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true
const r2 = useAppStore.getState().savePaths();
// 第二次调用同步返回 false(被守卫拦截)
await expect(r2).resolves.toBe(false);
// 第二次调用同步返回 blocked(被守卫拦截)
await expect(r2).resolves.toEqual({ kind: 'blocked' });
// 放行第一次调用的所有 invoke
resolveAll!(undefined);
@@ -285,21 +297,21 @@ describe('initialize', () => {
it('管理员模式初始化', async () => {
mockedInvoke
.mockResolvedValueOnce(true) // check_admin
.mockResolvedValueOnce(true) // check_admin
.mockResolvedValueOnce(['S1']) // load_system_paths
.mockResolvedValueOnce(['U1']); // load_user_paths
await useAppStore.getState().initialize();
const s = useAppStore.getState();
expect(s.isAdmin).toBe(true);
expect(s.sysPaths.map(e => e.path)).toEqual(['S1']);
expect(s.userPaths.map(e => e.path)).toEqual(['U1']);
expect(s.sysPaths.map((e) => e.path)).toEqual(['S1']);
expect(s.userPaths.map((e) => e.path)).toEqual(['U1']);
});
it('非管理员初始化进入只读模式', async () => {
mockedInvoke
.mockResolvedValueOnce(false) // check_admin
.mockResolvedValueOnce([]) // load_system_paths
.mockResolvedValueOnce([]); // load_user_paths
.mockResolvedValueOnce(false) // check_admin
.mockResolvedValueOnce([]) // load_system_paths
.mockResolvedValueOnce([]); // load_user_paths
await useAppStore.getState().initialize();
expect(useAppStore.getState().isAdmin).toBe(false);
// statusMessage 被后续 loadPaths 覆盖为加载完成消息,但 isAdmin=false 不变
+9 -5
View File
@@ -24,10 +24,14 @@ describe('exportToJson', () => {
it('导出结构化 JSON', () => {
const json = exportToJson(sampleData);
const parsed = JSON.parse(json);
expect(parsed.version).toBe('5.0.0');
expect(parsed.version).toBe('5.1.0');
expect(parsed.timestamp).toBeDefined();
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(sampleData.system.map(e => e.path));
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(sampleData.user.map(e => e.path));
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(
sampleData.system.map((e) => e.path),
);
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(
sampleData.user.map((e) => e.path),
);
expect(parsed.system[0].enabled).toBe(true);
expect(parsed.user[0].enabled).toBe(true);
});
@@ -36,8 +40,8 @@ describe('exportToJson', () => {
describe('importFromJson', () => {
it('正确导入 JSON', () => {
const json = JSON.stringify({
system: sampleData.system.map(e => e.path),
user: sampleData.user.map(e => e.path),
system: sampleData.system.map((e) => e.path),
user: sampleData.user.map((e) => e.path),
});
const result = importFromJson(json);
expect(result.system).toEqual(sampleData.system);
+6 -6
View File
@@ -5,27 +5,27 @@ describe('导入一致性(TS 端)', () => {
it('JSON 含 system + user', () => {
const json = JSON.stringify({ system: ['C:\\a', 'C:\\b'], user: ['D:\\c'] });
const r = importFromJson(json);
expect(r.system.map(e => e.path)).toEqual(['C:\\a', 'C:\\b']);
expect(r.user.map(e => e.path)).toEqual(['D:\\c']);
expect(r.system.map((e) => e.path)).toEqual(['C:\\a', 'C:\\b']);
expect(r.user.map((e) => e.path)).toEqual(['D:\\c']);
});
it('CSV system/user 分类', () => {
const csv = 'type,path\nsystem,C:\\sys\nuser,D:\\usr\n';
const r = importFromCsv(csv);
expect(r.system.map(e => e.path)).toEqual(['C:\\sys']);
expect(r.user.map(e => e.path)).toEqual(['D:\\usr']);
expect(r.system.map((e) => e.path)).toEqual(['C:\\sys']);
expect(r.user.map((e) => e.path)).toEqual(['D:\\usr']);
});
it('CSV 含 BOM + header', () => {
const csv = 'type,path\nsystem,C:\\x\n';
const r = importFromCsv(csv);
expect(r.system.map(e => e.path)).toEqual(['C:\\x']);
expect(r.system.map((e) => e.path)).toEqual(['C:\\x']);
});
it('TXT 逐行读取,跳过注释', () => {
const txt = '# comment\nC:\\a\n\nD:\\b\n';
const r = importFromTxt(txt);
expect(r.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
expect(r.map((e) => e.path)).toEqual(['C:\\a', 'D:\\b']);
});
it('JSON 空数据不崩溃', () => {
+16 -3
View File
@@ -9,15 +9,28 @@ vi.mock('@/store/app-store', () => ({
{ path: 'C:\\Windows', enabled: true },
{ path: 'C:\\Disabled', enabled: false },
],
userPaths: [
{ path: 'D:\\UserApp', enabled: true },
],
userPaths: [{ path: 'D:\\UserApp', enabled: true }],
searchQuery: '',
};
return selector(state);
}),
}));
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: (options: Record<string, number>) => ({
getVirtualItems: () => {
// return an array of objects to mock virtual items
return Array.from({ length: options.count }).map((_, index) => ({
index,
start: index * 28,
size: 28,
key: `mock-key-${index}`,
}));
},
getTotalSize: () => options.count * 28,
}),
}));
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string) => key) },
}));
+24 -5
View File
@@ -36,20 +36,39 @@ describe('analyzePaths', () => {
describe('pathClean', () => {
it('移除无效路径', () => {
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')], validateFn);
expect(kept.map(e => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
expect(removed.map(e => e.path)).toEqual(['C:\\Invalid']);
const [kept, removed] = pathClean(
[pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')],
validateFn,
);
expect(kept.map((e) => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
expect(removed.map((e) => e.path)).toEqual(['C:\\Invalid']);
});
it('移除重复路径保留第一个', () => {
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Valid'), pe('D:\\Valid')], alwaysValid);
const [kept, removed] = pathClean(
[pe('C:\\Valid'), pe('C:\\Valid'), pe('D:\\Valid')],
alwaysValid,
);
expect(kept.length).toBe(2);
expect(removed.length).toBe(1);
});
it('保留第一个出现的 enabled 状态', () => {
const [kept, removed] = pathClean([pe('C:\\Valid', false), pe('C:\\Valid', true)], alwaysValid);
expect(kept.length).toBe(1);
expect(kept[0].enabled).toBe(false); // 第一个状态
expect(removed.length).toBe(1);
});
it('全部有效无变化', () => {
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
expect(kept.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
expect(kept.map((e) => e.path)).toEqual(['C:\\a', 'D:\\b']);
expect(removed.length).toBe(0);
});
it('空数组处理', () => {
const [kept, removed] = pathClean([], alwaysValid);
expect(kept.length).toBe(0);
expect(removed.length).toBe(0);
});
+75 -20
View File
@@ -1,12 +1,24 @@
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 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 {
function makeRecord(
type: OperationType,
target: TargetType,
index: number,
count: number,
oldPaths: PathEntry[],
newPaths: PathEntry[],
): OpRecord {
return { type, target, index, count, oldPaths, newPaths };
}
@@ -31,10 +43,10 @@ describe('UndoRedoManager', () => {
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
const u = mgr.undo(sys, user)!;
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
const r = mgr.redo(...u)!;
expect(r[0].map(e => e.path)).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 撤销/重做', () => {
@@ -46,11 +58,20 @@ describe('UndoRedoManager', () => {
expect(u[0][0].path).toBe(removed.path);
const r = mgr.redo(...u)!;
expect(r[0].map(e => e.path)).toEqual(['C:\\Program Files']);
expect(r[0].map((e) => e.path)).toEqual(['C:\\Program Files']);
});
it('EDIT 撤销/重做', () => {
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, [pe('C:\\Windows')], [pe('C:\\Edited')]));
mgr.push(
makeRecord(
OperationType.EDIT,
TargetType.SYSTEM,
0,
1,
[pe('C:\\Windows')],
[pe('C:\\Edited')],
),
);
sys[0] = pe('C:\\Edited');
const u = mgr.undo(sys, user)!;
@@ -65,10 +86,10 @@ describe('UndoRedoManager', () => {
[sys[0], sys[1]] = [sys[1], sys[0]];
const u = mgr.undo(sys, user)!;
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
const r = mgr.redo(...u)!;
expect(r[0].map(e => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
expect(r[0].map((e) => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
});
it('MOVE_DOWN 撤销/重做', () => {
@@ -76,7 +97,7 @@ describe('UndoRedoManager', () => {
[sys[0], sys[1]] = [sys[1], sys[0]];
const u = mgr.undo(sys, user)!;
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
expect(u[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
});
it('CLEAN 撤销/重做', () => {
@@ -125,6 +146,26 @@ describe('UndoRedoManager', () => {
expect(mgr.canRedo()).toBe(false);
});
it('空历史栈的撤销与重做', () => {
expect(mgr.undo(sys, user)).toBeNull();
expect(mgr.redo(sys, user)).toBeNull();
});
it('超出栈底/栈顶的安全处理', () => {
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
sys.push(pe('C:\\NewPath'));
// undo一次
mgr.undo(sys, user);
// 再次undo,此时应到达底部返回null
expect(mgr.undo(sys, user)).toBeNull();
// redo一次
mgr.redo(sys, user);
// 再次redo,应到达顶部返回null
expect(mgr.redo(sys, user)).toBeNull();
});
it('超出最大历史容量时移除最旧记录', () => {
const small = new UndoRedoManager(3);
for (let i = 0; i < 5; i++) {
@@ -140,9 +181,12 @@ describe('UndoRedoManager', () => {
// 删除 indices [1, 3]C:\Program Files 和 C:\Extra2
const removed = [sys[1], sys[3]];
mgr.push({
type: OperationType.DELETE, target: TargetType.SYSTEM,
index: 1, count: 2,
oldPaths: removed, newPaths: [],
type: OperationType.DELETE,
target: TargetType.SYSTEM,
index: 1,
count: 2,
oldPaths: removed,
newPaths: [],
indices: [1, 3],
});
sys.splice(3, 1);
@@ -152,21 +196,29 @@ describe('UndoRedoManager', () => {
expect(u[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
expect(r[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
});
it('操作 USER 路径', () => {
user.push(pe('C:\\NewUserPath'));
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
const u = mgr.undo(sys, user)!;
expect(u[1].map(e => e.path)).toEqual(['C:\\Users\\me\\AppData']);
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
expect(u[1].map((e) => e.path)).toEqual(['C:\\Users\\me\\AppData']);
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)]));
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);
@@ -184,9 +236,12 @@ describe('UndoRedoManager', () => {
mgr.push({
type: OperationType.IMPORT_BOTH,
target: TargetType.SYSTEM,
index: 0, count: 0,
oldPaths: oldSys, newPaths: newSys,
oldPathsOther: oldUser, newPathsOther: newUser,
index: 0,
count: 0,
oldPaths: oldSys,
newPaths: newSys,
oldPathsOther: oldUser,
newPathsOther: newUser,
});
sys = newSys;
user = newUser;
+98 -37
View File
@@ -14,11 +14,13 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({
}));
vi.mock('@/i18n', () => ({
default: { t: vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'status.deleted') return `已删除 ${opts?.count}`;
if (key === 'status.saveWarningLongPaths') return 'PATH 长度超限';
return key;
}) },
default: {
t: vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'status.deleted') return `已删除 ${opts?.count}`;
if (key === 'status.saveWarningLongPaths') return 'PATH 长度超限';
return key;
}),
},
}));
vi.mock('@/hooks/use-keyboard', () => ({
@@ -83,7 +85,9 @@ describe('useAppActions', () => {
it('handleNew 打开新建弹窗', async () => {
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleNew(); });
act(() => {
result.current.handleNew();
});
expect(dialogs.setNewDialog).toHaveBeenCalledWith(true);
});
@@ -93,9 +97,14 @@ describe('useAppActions', () => {
useAppStore.setState({ selectedIndices: [0] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleEdit(); });
act(() => {
result.current.handleEdit();
});
expect(dialogs.setEditDialog).toHaveBeenCalledWith({
open: true, index: 0, value: 'C:\\Windows', target: TargetType.SYSTEM,
open: true,
index: 0,
value: 'C:\\Windows',
target: TargetType.SYSTEM,
});
});
@@ -103,7 +112,9 @@ describe('useAppActions', () => {
useAppStore.setState({ selectedIndices: [] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleEdit(); });
act(() => {
result.current.handleEdit();
});
expect(dialogs.setEditDialog).not.toHaveBeenCalled();
});
@@ -113,15 +124,19 @@ describe('useAppActions', () => {
useAppStore.setState({ selectedIndices: [0] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleDelete(); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\Program Files']);
act(() => {
result.current.handleDelete();
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\Program Files']);
});
it('handleDelete 无选中项不操作', async () => {
useAppStore.setState({ selectedIndices: [] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleDelete(); });
act(() => {
result.current.handleDelete();
});
expect(useAppStore.getState().sysPaths.length).toBe(2);
});
@@ -131,16 +146,26 @@ describe('useAppActions', () => {
useAppStore.setState({ selectedIndices: [1] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleMoveUp(); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
act(() => {
result.current.handleMoveUp();
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual([
'C:\\Program Files',
'C:\\Windows',
]);
});
it('handleMoveDown 下移选中项', async () => {
useAppStore.setState({ selectedIndices: [0] });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleMoveDown(); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\Program Files', 'C:\\Windows']);
act(() => {
result.current.handleMoveDown();
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual([
'C:\\Program Files',
'C:\\Windows',
]);
});
// ── handleClean ──
@@ -149,8 +174,10 @@ describe('useAppActions', () => {
resetStore([pe('C:\\Windows'), pe('invalid_path!@#')]);
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleClean(); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\Windows']);
act(() => {
result.current.handleClean();
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\Windows']);
expect(useAppStore.getState().statusMessage).toContain('已删除 1 条');
});
@@ -159,15 +186,19 @@ describe('useAppActions', () => {
it('handleNewConfirm 添加新路径', async () => {
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleNewConfirm('C:\\New'); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toContain('C:\\New');
act(() => {
result.current.handleNewConfirm('C:\\New');
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toContain('C:\\New');
expect(dialogs.setNewDialog).toHaveBeenCalledWith(false);
});
it('handleNewConfirm 空白不添加', async () => {
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleNewConfirm(' '); });
act(() => {
result.current.handleNewConfirm(' ');
});
expect(useAppStore.getState().sysPaths.length).toBe(2);
});
@@ -177,7 +208,9 @@ describe('useAppActions', () => {
dialogs.editDialog = { open: true, index: 0, value: 'C:\\Windows', target: TargetType.SYSTEM };
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleEditConfirm('C:\\Edited'); });
act(() => {
result.current.handleEditConfirm('C:\\Edited');
});
expect(useAppStore.getState().sysPaths[0].path).toBe('C:\\Edited');
});
@@ -189,45 +222,73 @@ describe('useAppActions', () => {
dialogs.importDialog = { open: true, system: sysImport, user: usrImport };
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleImportSelect('both'); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\ImportSys']);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['D:\\ImportUsr']);
act(() => {
result.current.handleImportSelect('both');
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\ImportSys']);
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['D:\\ImportUsr']);
expect(dialogs.setImportDialog).toHaveBeenCalledWith({ open: false, system: [], user: [] });
});
it('handleImportSelect system 模式只替换 system', async () => {
dialogs.importDialog = { open: true, system: [pe('C:\\ImportSys')], user: [pe('D:\\ImportUsr')] };
dialogs.importDialog = {
open: true,
system: [pe('C:\\ImportSys')],
user: [pe('D:\\ImportUsr')],
};
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
act(() => { result.current.handleImportSelect('system'); });
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\ImportSys']);
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['D:\\User']); // 未变
act(() => {
result.current.handleImportSelect('system');
});
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\ImportSys']);
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['D:\\User']); // 未变
});
// ── handleSave ──
it('handleSave 正常保存', async () => {
mockedInvoke.mockResolvedValue(undefined);
vi.spyOn(useAppStore.getState(), 'savePaths').mockResolvedValue({ kind: 'success' });
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
await act(async () => { await result.current.handleSave(); });
// invoke 被调用(backup + save_system + save_user + broadcast
expect(mockedInvoke).toHaveBeenCalled();
await act(async () => {
await result.current.handleSave();
});
// savePaths is called
expect(useAppStore.getState().savePaths).toHaveBeenCalled();
});
it('handleSave 超长确认后强制保存', async () => {
// 第一次 savePaths 返回 false(超长)
// 第二次(force=true)返回 true
// 第一次 savePaths 返回 warning(超长)
// 第二次(force=true)返回 success
let callCount = 0;
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async (force?: boolean) => {
callCount++;
if (!force) return false; // 第一次:超长警告
return true; // 第二次:强制保存成功
if (!force) return { kind: 'warning', reason: 'lengthExceeded' }; // 第一次:超长警告
return { kind: 'success' }; // 第二次:强制保存成功
});
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
await act(async () => { await result.current.handleSave(); });
await act(async () => {
await result.current.handleSave();
});
expect(callCount).toBe(2);
expect(mockAsk).toHaveBeenCalled();
});
it('handleSave 普通失败不弹确认框', async () => {
let callCount = 0;
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async () => {
callCount++;
return { kind: 'failure', message: '权限不足' };
});
const { useAppActions } = await import('@/hooks/use-app-actions');
const { result } = renderHook(() => useAppActions('system', dialogs));
await act(async () => {
await result.current.handleSave();
});
expect(callCount).toBe(1); // 仅调用一次,不重试
expect(mockAsk).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -12,8 +12,9 @@ export default defineConfig({
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
reporter: ['text', 'lcov', 'cobertura'],
include: ['src/core/**', 'src/store/**', 'src/hooks/**'],
exclude: ['src/main.tsx', 'src/vite-env.d.ts'],
thresholds: {
lines: 80,
},