mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-30 02:25:55 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5f3f5750c | |||
| 8c0e80d862 |
@@ -0,0 +1,5 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# EditorConfig — 跨编辑器统一代码风格
|
||||||
|
# https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = crlf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{rs,toml}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Git 行尾符规范化
|
||||||
|
# 本仓库统一 CRLF(Windows 原生项目)
|
||||||
|
|
||||||
|
# 源码文本文件
|
||||||
|
*.ts text eol=crlf
|
||||||
|
*.tsx text eol=crlf
|
||||||
|
*.js text eol=crlf
|
||||||
|
*.json text eol=crlf
|
||||||
|
*.html text eol=crlf
|
||||||
|
*.css text eol=crlf
|
||||||
|
*.md text eol=crlf
|
||||||
|
*.rs text eol=crlf
|
||||||
|
*.toml text eol=crlf
|
||||||
|
*.yml text eol=crlf
|
||||||
|
*.yaml text eol=crlf
|
||||||
|
*.svg text eol=crlf
|
||||||
|
*.txt text eol=crlf
|
||||||
|
*.editorconfig text eol=crlf
|
||||||
|
*.gitattributes text eol=crlf
|
||||||
|
*.gitignore text eol=crlf
|
||||||
|
LICENSE text eol=crlf
|
||||||
|
|
||||||
|
# 二进制文件
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.dll binary
|
||||||
|
*.exe binary
|
||||||
|
*.nsis binary
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 代码所有者 — 自动分配 PR 审查
|
||||||
|
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||||
|
|
||||||
|
# 全局所有者
|
||||||
|
* @LHY0125
|
||||||
|
|
||||||
|
# Rust 代码
|
||||||
|
/core/ @LHY0125
|
||||||
|
/cli/ @LHY0125
|
||||||
|
/gui/ @LHY0125
|
||||||
|
/Cargo.toml @LHY0125
|
||||||
|
/rust-toolchain.toml @LHY0125
|
||||||
|
|
||||||
|
# 前端代码
|
||||||
|
/src/ @LHY0125
|
||||||
|
/tests/ @LHY0125
|
||||||
|
/e2e/ @LHY0125
|
||||||
|
|
||||||
|
# CI/CD 和配置文件
|
||||||
|
/.github/ @LHY0125
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 开源赞助
|
||||||
|
# 支持 PathEditor 的开发
|
||||||
|
|
||||||
|
github: LHY0125
|
||||||
|
# 如需定制功能或商业授权,请通过 GitHub Issues 联系
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Dependabot 自动依赖更新配置
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# npm 前端依赖
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "09:00"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
versioning-strategy: "auto"
|
||||||
|
allow:
|
||||||
|
- dependency-type: "all"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "javascript"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
prefix-development: "chore(deps-dev)"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
react:
|
||||||
|
patterns:
|
||||||
|
- "react"
|
||||||
|
- "react-dom"
|
||||||
|
- "@types/react"
|
||||||
|
- "@types/react-dom"
|
||||||
|
tauri:
|
||||||
|
patterns:
|
||||||
|
- "@tauri-apps/*"
|
||||||
|
testing:
|
||||||
|
patterns:
|
||||||
|
- "@testing-library/*"
|
||||||
|
- "@playwright/test"
|
||||||
|
- "vitest"
|
||||||
|
- "jsdom"
|
||||||
|
eslint:
|
||||||
|
patterns:
|
||||||
|
- "eslint"
|
||||||
|
- "eslint-plugin-*"
|
||||||
|
- "typescript-eslint"
|
||||||
|
- "globals"
|
||||||
|
- "@eslint/js"
|
||||||
|
|
||||||
|
# Cargo Rust 依赖
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "09:00"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "rust"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
prefix-development: "chore(deps-dev)"
|
||||||
|
open-pull-requests-limit: 3
|
||||||
|
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "ci"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci(deps)"
|
||||||
@@ -12,7 +12,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: 前端检查 (TypeScript + Lint + Test)
|
name: 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -24,21 +24,35 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Prettier 格式检查
|
||||||
|
run: npx prettier --check "src/**/*.{ts,tsx}" "tests/**/*.{ts,tsx}" "e2e/**/*.ts"
|
||||||
|
|
||||||
- name: TypeScript 类型检查
|
- name: TypeScript 类型检查
|
||||||
run: npx tsc -b --noEmit
|
run: npx tsc -b --noEmit
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: npx eslint src/ tests/
|
run: npx eslint src/ tests/
|
||||||
|
|
||||||
- name: Vitest 测试
|
- name: Vitest 测试 + 覆盖率
|
||||||
run: npm test
|
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:
|
rust:
|
||||||
name: Rust 检查 (Check + Clippy + Test)
|
name: Rust 检查 (格式 + Check + Clippy + Test)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cargo Format
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
- name: Cargo Check
|
- name: Cargo Check
|
||||||
run: cargo check
|
run: cargo check
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -12,8 +12,21 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Sync conflicts
|
||||||
|
*.sync-conflict-*
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
.codegraph/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -22,8 +35,16 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# AI assistant
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Platform
|
||||||
e2e/debug-screenshot.png
|
e2e/debug-screenshot.png
|
||||||
test-results/
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Archive
|
||||||
|
*.zip
|
||||||
|
*.7z
|
||||||
|
*.tar.gz
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit $1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"default": true,
|
||||||
|
"MD013": false,
|
||||||
|
"MD033": {
|
||||||
|
"allowed_elements": ["img", "br", "kbd", "summary", "details"]
|
||||||
|
},
|
||||||
|
"MD041": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
target
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
test-results
|
||||||
|
coverage
|
||||||
|
Cargo.lock
|
||||||
|
package-lock.json
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "crlf",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
@@ -11,11 +11,33 @@
|
|||||||
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
|
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
|
||||||
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
|
<img src="https://img.shields.io/badge/tests-72%20passed-brightgreen" alt="tests">
|
||||||
|
<a href="https://codecov.io/gh/LHY0125/PathEditor"><img src="https://codecov.io/gh/LHY0125/PathEditor/branch/v5.0/graph/badge.svg" alt="coverage"></a>
|
||||||
|
<img src="https://img.shields.io/badge/platform-Windows%2010%2B-0078D6" alt="platform">
|
||||||
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
|
<img src="https://github.com/LHY0125/PathEditor/actions/workflows/ci.yml/badge.svg" alt="CI">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
### 主界面
|
||||||
|
|
||||||
|

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

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

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

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
|
|
||||||
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
|
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
|
||||||
@@ -252,7 +274,7 @@ npx tauri build
|
|||||||
### 技术栈
|
### 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
|---|---|
|
| --------- | --------------------------------- |
|
||||||
| 前端框架 | React 19 + TypeScript (strict) |
|
| 前端框架 | React 19 + TypeScript (strict) |
|
||||||
| UI 样式 | Tailwind CSS 4 |
|
| UI 样式 | Tailwind CSS 4 |
|
||||||
| 状态管理 | Zustand |
|
| 状态管理 | Zustand |
|
||||||
@@ -291,7 +313,7 @@ tests/unit/ # 前端单元测试
|
|||||||
## 快捷键
|
## 快捷键
|
||||||
|
|
||||||
| 快捷键 | 功能 |
|
| 快捷键 | 功能 |
|
||||||
|--------|------|
|
| -------- | -------- |
|
||||||
| `Ctrl+N` | 新建路径 |
|
| `Ctrl+N` | 新建路径 |
|
||||||
| `Ctrl+S` | 保存 |
|
| `Ctrl+S` | 保存 |
|
||||||
| `Ctrl+Z` | 撤销 |
|
| `Ctrl+Z` | 撤销 |
|
||||||
|
|||||||
+48
@@ -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
@@ -0,0 +1,45 @@
|
|||||||
|
# 获取帮助
|
||||||
|
|
||||||
|
## 📖 文档
|
||||||
|
|
||||||
|
- [README](README.md) — 项目简介、功能列表、安装指南
|
||||||
|
- [CONTRIBUTING](CONTRIBUTING.md) — 贡献指南
|
||||||
|
- [CHANGELOG](CHANGELOG.md) — 版本变更记录
|
||||||
|
- [ROADMAP](ROADMAP.md) — 未来规划
|
||||||
|
- [SECURITY](SECURITY.md) — 安全政策
|
||||||
|
|
||||||
|
## 🐛 报告 Bug
|
||||||
|
|
||||||
|
1. 先搜索 [Issues](https://github.com/LHY0125/PathEditor/issues) 确认未被报告
|
||||||
|
2. 使用 **Bug Report** 模板创建新 Issue
|
||||||
|
3. 提供系统信息(Windows 版本、PathEditor 版本)
|
||||||
|
4. 附上复现步骤和截图
|
||||||
|
|
||||||
|
## 💡 功能建议
|
||||||
|
|
||||||
|
1. 检查 [ROADMAP](ROADMAP.md) 确认不在已有计划中
|
||||||
|
2. 使用 **Feature Request** 模板创建新 Issue
|
||||||
|
3. 描述使用场景和期望行为
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
### CLI 命令找不到?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
patheditor --help
|
||||||
|
```
|
||||||
|
|
||||||
|
确保已通过 `cargo install --path cli` 安装,且 `~/.cargo/bin` 在 PATH 中。
|
||||||
|
|
||||||
|
### 提示权限不足?
|
||||||
|
|
||||||
|
编辑系统 PATH 需要管理员权限。右键以管理员身份运行,或使用 CLI `patheditor check-admin` 检测。
|
||||||
|
|
||||||
|
### 保存后环境变量未生效?
|
||||||
|
|
||||||
|
PathEditor 会自动广播 `WM_SETTINGCHANGE`,但部分程序需要手动重启才能识别新 PATH。
|
||||||
|
|
||||||
|
## 📧 联系
|
||||||
|
|
||||||
|
- GitHub Issues: [LHY0125/PathEditor](https://github.com/LHY0125/PathEditor/issues)
|
||||||
|
- 安全问题: 参见 [SECURITY.md](SECURITY.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('@commitlint/types').UserConfig} */
|
||||||
|
export default {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [
|
||||||
|
2,
|
||||||
|
'always',
|
||||||
|
['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'perf', 'ci', 'style', 'revert'],
|
||||||
|
],
|
||||||
|
'subject-case': [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 未修复问题清单
|
||||||
|
|
||||||
|
> 从 v5.1 全面代码审查中筛选,暂不修复,留待后续评估。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CLI main.rs 单体文件 (639 行)
|
||||||
|
|
||||||
|
**严重级别**: LOW
|
||||||
|
**文件**: `cli/src/main.rs`
|
||||||
|
|
||||||
|
**问题**: 所有 18 条 CLI 命令集中在一个文件中。
|
||||||
|
|
||||||
|
**建议**: 当前规模尚可维护,等到命令数超过 25 条或文件超过 1000 行时再拆分为 `commands/` 子模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. GUI 命令层零测试
|
||||||
|
|
||||||
|
**严重级别**: LOW
|
||||||
|
**文件**: `gui/src/commands/*.rs` (8 个文件)
|
||||||
|
|
||||||
|
**问题**: GUI 命令层是纯薄包装,无独立测试。
|
||||||
|
|
||||||
|
**建议**: 不值得投入 — 命令正确性由编译器类型系统保证,运行期由 57 个 core 测试 + E2E 覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已修复(本批次)
|
||||||
|
|
||||||
|
- ~~disabled.rs 测试写入真实文件~~ → `#[cfg(test)]` 条件编译重定向到 `std::env::temp_dir()`
|
||||||
|
- ~~profiles.rs 同款问题~~ → 同上
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_更新于: 2026-05-30 | 审查批次: v5.1 代码审查_
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# PathEditor v5.0 代码与架构审查报告
|
||||||
|
|
||||||
|
## 1. 项目概览
|
||||||
|
|
||||||
|
PathEditor v5.0 是一个功能完善的 Windows 系统环境变量 (PATH) 编辑器,支持 GUI 与 CLI 双模式。
|
||||||
|
技术栈选型现代化且合理:
|
||||||
|
|
||||||
|
- **后端 / 核心逻辑**:Rust (Cargo Workspace)
|
||||||
|
- **GUI 框架**:Tauri 2.x
|
||||||
|
- **前端**:React 19 + TypeScript + Zustand
|
||||||
|
|
||||||
|
整体项目结构清晰,职责划分明确,严格遵循了前后端分离与核心逻辑无平台依赖的设计原则。
|
||||||
|
|
||||||
|
## 2. 架构设计审查
|
||||||
|
|
||||||
|
### 2.1 Cargo Workspace 三层架构
|
||||||
|
|
||||||
|
项目采用了经典的 Cargo Workspace 模式,分为三层:
|
||||||
|
|
||||||
|
- `core`: 纯 Rust 库 crate,包含所有的核心业务逻辑(注册表读写、备份、配置文件管理、路径验证与清理等)。该层**完全不依赖** Tauri 或 CLI 库,极大地提高了代码的复用性和可测试性。
|
||||||
|
- `gui`: Tauri 桌面应用。仅作为薄包装层(Thin Wrapper),通过 `#[tauri::command]` 将 `core` 的功能暴露为 IPC 接口供前端调用。
|
||||||
|
- `cli`: 命令行工具层。依赖 `core` 和 `clap` 库,直接提供命令行交互能力。
|
||||||
|
|
||||||
|
**审查结论**:架构设计非常优秀。核心逻辑解耦彻底,无论是 GUI 还是 CLI 都能复用同一套安全、经过测试的核心代码。
|
||||||
|
|
||||||
|
### 2.2 IPC 通信与状态同步
|
||||||
|
|
||||||
|
前端与 Rust 后端通过 Tauri IPC 进行通信。
|
||||||
|
|
||||||
|
- 所有的错误处理均通过 `Result<T, String>` 返回,前端通过 `Promise` 捕获并处理,用户体验良好。
|
||||||
|
- 针对非事务性的双写操作(如同时保存系统和用户 PATH),前端 `app-store.ts` 中使用了 `Promise.allSettled`。当发生部分成功(Partial Success)时,能正确捕获并重新加载注册表状态,避免了前端内存状态与后端注册表状态的漂移(State Drift)。
|
||||||
|
|
||||||
|
## 3. 后端代码审查 (Rust)
|
||||||
|
|
||||||
|
### 3.1 核心逻辑 (`core`)
|
||||||
|
|
||||||
|
- **安全性与健壮性**:
|
||||||
|
- 在 `registry.rs` 中,严格检查了路径字符串的 Null 字节,以及 32767 个字符的 Windows 注册表长度上限,防止缓冲区溢出或写入失败。
|
||||||
|
- 使用了安全的 `winreg` 库进行注册表操作。
|
||||||
|
- **FFI 调用**:
|
||||||
|
- 在 `system.rs` 中调用 Windows API(如 `ExpandEnvironmentStringsW` 和 `SendMessageTimeoutW`)时,对 `unsafe` 代码块进行了详尽的 SAFETY 注释。
|
||||||
|
- 能够妥善处理 UTF-16 编码和解码,保留非法码点避免丢失路径信息,细节处理非常到位。
|
||||||
|
|
||||||
|
### 3.2 命令行工具 (`cli`)
|
||||||
|
|
||||||
|
- **原子性与并发安全**:
|
||||||
|
- 在 CLI 的 `verify_and_save` 逻辑中,写入前会重新读取注册表并与原始状态对比。如果不一致,则拒绝写入并报错退出。这有效地防止了并发情况下的配置覆盖问题。
|
||||||
|
- **用户体验**:
|
||||||
|
- 命令设计符合直觉,支持 `--dry-run` 预览以及 JSON 格式输出,方便与其他脚本集成。
|
||||||
|
|
||||||
|
## 4. 前端代码审查 (React + TypeScript)
|
||||||
|
|
||||||
|
### 4.1 状态管理 (`app-store.ts`)
|
||||||
|
|
||||||
|
- 使用 `Zustand` 进行全局状态管理,状态树设计合理,避免了 React Context 可能带来的不必要重渲染。
|
||||||
|
- 实现了完善的 `UndoRedoManager`,将每一步操作抽象为 `OperationType`,支持撤销/重做功能,这对于编辑器类应用来说是核心体验的加分项。
|
||||||
|
- `isSaving` 状态守卫有效防止了用户双击保存按钮引发的并发竞争。
|
||||||
|
|
||||||
|
### 4.2 UI 与逻辑分离
|
||||||
|
|
||||||
|
- 业务逻辑抽象到 `src/core` 目录下(如 `path-manager.ts`, `validation.ts`),UI 组件仅负责渲染和事件绑定。
|
||||||
|
- `useAppActions.ts` 钩子巧妙地将组件层与 Store 状态操作解耦,使得组件代码极其整洁。
|
||||||
|
|
||||||
|
## 5. 改进建议 (Recommendations)
|
||||||
|
|
||||||
|
虽然当前代码质量已经很高,但仍有以下几个方面可以进一步优化:
|
||||||
|
|
||||||
|
1. **Rust FFI 维护性**:
|
||||||
|
当前 `system.rs` 中手动声明了 `extern "system"` 函数。建议引入 `windows-rs` 或 `windows-sys` 库,这能提供微软官方维护的安全的 API 绑定,减少手动编写 FFI 签名带来的维护成本和潜在错误。
|
||||||
|
2. **GUI 保存的并发安全 (Race Condition)**:
|
||||||
|
CLI 已经实现了保存前的二次状态比对(`verify_and_save`),但在 `gui/src/commands/registry.rs` 中,直接调用了 `save_system_paths`。如果在用户打开 GUI 修改期间,另一个进程修改了注册表,GUI 保存时可能会覆盖该修改。建议在 GUI 的 IPC 保存接口中,也引入类似 CLI 的版本校验(例如传入 `expected_original_paths` 进行比对)。
|
||||||
|
3. **前端单元测试覆盖**:
|
||||||
|
核心逻辑如 `undo-redo.ts` 和 `path-manager.ts` 纯函数特性明显,建议在 `tests/unit/` 下增加对这些文件的边界用例测试,确保复杂编辑操作下状态不崩溃。
|
||||||
|
4. **长列表性能**:
|
||||||
|
如果 PATH 环境变量条目非常多(虽然实际场景中一般在 100 条以内),React 渲染完整列表可能会有微小延迟。当前规模下无影响,但若未来考虑显示大量工具链路径扫描结果,可引入虚拟列表(Virtual List)。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
PathEditor v5.0 的代码库是一个优秀的 Rust + Tauri + React 实践范例。它具有清晰的三层架构、严格的类型和边界检查、以及良好的错误处理机制,整体架构稳健且易于长期维护。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 截图目录
|
||||||
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 |
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
interface Window {
|
||||||
|
__TAURI_INTERNALS__?: {
|
||||||
|
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,22 +6,36 @@ test.beforeEach(async ({ page }) => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
invoke: async (cmd, args) => {
|
invoke: async (cmd, args) => {
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'check_admin': return true;
|
case 'check_admin':
|
||||||
case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
|
return true;
|
||||||
case 'load_user_paths': return [];
|
case 'load_system_paths':
|
||||||
case 'load_disabled_state': return { system: [], user: [] };
|
return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
|
||||||
case 'save_system_paths': return undefined;
|
case 'load_user_paths':
|
||||||
case 'save_user_paths': return undefined;
|
return [];
|
||||||
case 'save_disabled_state': return undefined;
|
case 'load_disabled_state':
|
||||||
case 'backup_registry': return '';
|
return { system: [], user: [] };
|
||||||
case 'broadcast_env_change': return undefined;
|
case 'save_system_paths':
|
||||||
case 'validate_path': return false;
|
return undefined;
|
||||||
case 'expand_env_vars': return '';
|
case 'save_user_paths':
|
||||||
case 'read_text_file': return '';
|
return undefined;
|
||||||
case 'get_appdata_dir': return '';
|
case 'save_disabled_state':
|
||||||
default: return undefined;
|
return undefined;
|
||||||
}
|
case 'backup_registry':
|
||||||
|
return '';
|
||||||
|
case 'broadcast_env_change':
|
||||||
|
return undefined;
|
||||||
|
case 'validate_path':
|
||||||
|
return false;
|
||||||
|
case 'expand_env_vars':
|
||||||
|
return '';
|
||||||
|
case 'read_text_file':
|
||||||
|
return '';
|
||||||
|
case 'get_appdata_dir':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PathEditor v4.0</title>
|
<title>PathEditor v5.0</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+1524
-44
File diff suppressed because it is too large
Load Diff
+20
-1
@@ -3,14 +3,27 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx}": [
|
||||||
|
"prettier --write",
|
||||||
|
"eslint --fix"
|
||||||
|
],
|
||||||
|
"*.{json,md,css,html}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"tests/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test --config e2e/playwright.config.ts"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test --config e2e/playwright.config.ts",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
@@ -25,6 +38,8 @@
|
|||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^21.0.2",
|
||||||
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
@@ -34,11 +49,15 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable-x86_64-pc-windows-gnu"
|
||||||
@@ -66,7 +66,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
|||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
<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>
|
<h2 className="text-base font-semibold">{t('analyze.title')}</h2>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
|
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
|
||||||
@@ -88,7 +91,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
|||||||
{/* 内容 */}
|
{/* 内容 */}
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
{loading ? (
|
{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')}
|
{t('analyze.scanning')}
|
||||||
</div>
|
</div>
|
||||||
) : tab === 'conflicts' ? (
|
) : tab === 'conflicts' ? (
|
||||||
@@ -214,5 +220,7 @@ function EmptyHint({ text }: { text: string }) {
|
|||||||
|
|
||||||
function getEnabledPaths(): string[] {
|
function getEnabledPaths(): string[] {
|
||||||
const { sysPaths, userPaths } = useAppStore.getState();
|
const { sysPaths, userPaths } = useAppStore.getState();
|
||||||
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path);
|
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map(
|
||||||
|
(e) => e.path,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
interface HelpDialogProps { open: boolean; onClose: () => void; }
|
interface HelpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -9,9 +12,15 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
|
<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">
|
<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')}
|
{t('dialog.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ interface ImportDialogProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel }: ImportDialogProps) {
|
export function ImportDialog({
|
||||||
|
open,
|
||||||
|
systemCount,
|
||||||
|
userCount,
|
||||||
|
onSelect,
|
||||||
|
onCancel,
|
||||||
|
}: ImportDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,10 +27,40 @@ export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel
|
|||||||
{userCount > 0 && `用户变量: ${userCount} 条`}
|
{userCount > 0 && `用户变量: ${userCount} 条`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
<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>}
|
{systemCount > 0 && (
|
||||||
{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>}
|
<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>}
|
className="px-4 py-2 text-sm rounded border text-left"
|
||||||
<button className="px-4 py-2 text-sm rounded border mt-2" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>{t('dialog.cancel')}</button>
|
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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
@@ -10,30 +10,54 @@ interface PathEditDialogProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel }: PathEditDialogProps) {
|
export function PathEditDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
initialValue,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: PathEditDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const prevOpen = useRef(open);
|
||||||
|
|
||||||
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (open && !prevOpen.current) setValue(initialValue);
|
||||||
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
prevOpen.current = open;
|
||||||
|
}, [open, initialValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onCancel}>
|
<Modal open={open} onClose={onCancel}>
|
||||||
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
||||||
<input
|
<input
|
||||||
type="text" autoFocus value={value}
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.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"
|
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">
|
<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')}
|
{t('dialog.cancel')}
|
||||||
</button>
|
</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')}
|
{t('dialog.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
if (!selected || !selectedData) return;
|
if (!selected || !selectedData) return;
|
||||||
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
|
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
|
||||||
useAppStore.getState().replaceBothPaths(
|
useAppStore.getState().replaceBothPaths(
|
||||||
selectedData.sys.map(e => e.path),
|
selectedData.sys.map((e) => e.path),
|
||||||
selectedData.user.map(e => e.path),
|
selectedData.user.map((e) => e.path),
|
||||||
);
|
);
|
||||||
// 同步 disabled 状态
|
// 同步 disabled 状态
|
||||||
await invoke('save_disabled_state', {
|
await invoke('save_disabled_state', {
|
||||||
system: selectedData.sys.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),
|
user: selectedData.user.filter((e) => !e.enabled).map((e) => e.path),
|
||||||
});
|
});
|
||||||
await useAppStore.getState().savePaths();
|
await useAppStore.getState().savePaths();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -80,7 +80,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
if (!window.confirm(`删除配置文件 "${name}"?`)) return;
|
if (!window.confirm(`删除配置文件 "${name}"?`)) return;
|
||||||
await invoke('delete_profile', { name });
|
await invoke('delete_profile', { name });
|
||||||
if (selected === name) { setSelected(null); setSelectedData(null); }
|
if (selected === name) {
|
||||||
|
setSelected(null);
|
||||||
|
setSelectedData(null);
|
||||||
|
}
|
||||||
refreshProfiles();
|
refreshProfiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,16 +98,23 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
<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>
|
<h2 className="text-base font-semibold">{t('profile.title')}</h2>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={e => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder={t('profile.namePlaceholder')}
|
placeholder={t('profile.namePlaceholder')}
|
||||||
className="px-2 py-1 text-sm rounded border outline-none w-44"
|
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
|
<button
|
||||||
className="px-3 py-1 text-sm rounded text-white"
|
className="px-3 py-1 text-sm rounded text-white"
|
||||||
@@ -127,11 +137,16 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<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 ? (
|
{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
|
<div
|
||||||
key={p.name}
|
key={p.name}
|
||||||
onClick={() => handleLoad(p.name)}
|
onClick={() => handleLoad(p.name)}
|
||||||
@@ -157,7 +172,9 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="font-semibold text-sm">{selectedData.name}</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-1.5 mb-3">
|
<div className="flex gap-1.5 mb-3">
|
||||||
@@ -171,7 +188,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="px-3 py-1 text-xs rounded"
|
className="px-3 py-1 text-xs rounded"
|
||||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
|
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')}
|
{t('profile.rename')}
|
||||||
</button>
|
</button>
|
||||||
@@ -189,18 +209,32 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={renameValue}
|
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"
|
className="px-2 py-1 text-xs rounded border outline-none"
|
||||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
style={{
|
||||||
|
backgroundColor: 'var(--app-list-bg)',
|
||||||
|
color: 'var(--app-fg)',
|
||||||
|
borderColor: 'var(--app-border)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="px-2 py-1 text-xs rounded text-white" style={{ backgroundColor: '#3b82f6' }} onClick={handleRename}>
|
<button
|
||||||
|
className="px-2 py-1 text-xs rounded text-white"
|
||||||
|
style={{ backgroundColor: '#3b82f6' }}
|
||||||
|
onClick={handleRename}
|
||||||
|
>
|
||||||
确认
|
确认
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PathSection title={`系统 PATH (${selectedData.sys.length})`} paths={selectedData.sys} />
|
<PathSection
|
||||||
<PathSection title={`用户 PATH (${selectedData.user.length})`} paths={selectedData.user} />
|
title={`系统 PATH (${selectedData.sys.length})`}
|
||||||
|
paths={selectedData.sys}
|
||||||
|
/>
|
||||||
|
<PathSection
|
||||||
|
title={`用户 PATH (${selectedData.user.length})`}
|
||||||
|
paths={selectedData.user}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,9 +247,13 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
|
function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<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 ? (
|
{paths.length === 0 ? (
|
||||||
<div className="text-xs" style={{ opacity: 0.4 }}>(空)</div>
|
<div className="text-xs" style={{ opacity: 0.4 }}>
|
||||||
|
(空)
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5 max-h-48 overflow-auto">
|
<div className="space-y-0.5 max-h-48 overflow-auto">
|
||||||
{paths.map((e, i) => (
|
{paths.map((e, i) => (
|
||||||
|
|||||||
@@ -28,19 +28,32 @@ export function AppShell() {
|
|||||||
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
||||||
|
|
||||||
const [editDialog, setEditDialog] = useState<DialogState['editDialog']>({
|
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 [newDialog, setNewDialog] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
||||||
open: false, system: [], user: [],
|
open: false,
|
||||||
|
system: [],
|
||||||
|
user: [],
|
||||||
});
|
});
|
||||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
||||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||||
|
|
||||||
const actions = useAppActions(activeTab, {
|
const actions = useAppActions(activeTab, {
|
||||||
editDialog, newDialog, helpOpen, importDialog,
|
editDialog,
|
||||||
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
|
newDialog,
|
||||||
|
helpOpen,
|
||||||
|
importDialog,
|
||||||
|
setEditDialog,
|
||||||
|
setNewDialog,
|
||||||
|
setHelpOpen,
|
||||||
|
setImportDialog,
|
||||||
|
setAnalyzeOpen,
|
||||||
|
setProfilesOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabConfig: { id: TabId; label: string }[] = [
|
const tabConfig: { id: TabId; label: string }[] = [
|
||||||
@@ -50,14 +63,20 @@ export function AppShell() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 />
|
<TitleBar />
|
||||||
|
|
||||||
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
|
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
|
||||||
{tabConfig.map((tab) => (
|
{tabConfig.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
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'}`}
|
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)' }}
|
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
@@ -96,7 +115,10 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-auto"
|
className="flex-1 overflow-auto"
|
||||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'link';
|
||||||
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeTab === 'merged') return;
|
if (activeTab === 'merged') return;
|
||||||
@@ -104,20 +126,47 @@ export function AppShell() {
|
|||||||
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
||||||
if (entry?.isDirectory) {
|
if (entry?.isDirectory) {
|
||||||
const file = e.dataTransfer.files[i] as TauriFile;
|
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>
|
</div>
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
<PathEditDialog open={newDialog} title={t('dialog.newPath')} initialValue="" onConfirm={actions.handleNewConfirm} onCancel={() => setNewDialog(false)} />
|
<PathEditDialog
|
||||||
<PathEditDialog open={editDialog.open} title={t('dialog.editPath')} initialValue={editDialog.value} onConfirm={actions.handleEditConfirm} onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} />
|
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)} />
|
<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)} />
|
<AnalyzeDialog open={analyzeOpen} onClose={() => setAnalyzeOpen(false)} />
|
||||||
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
|
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Component, type ReactNode } from 'react';
|
import { Component, type ReactNode } from 'react';
|
||||||
|
|
||||||
interface Props { children: ReactNode; }
|
interface Props {
|
||||||
interface State { hasError: boolean; error: string; }
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
state: State = { hasError: false, error: '' };
|
state: State = { hasError: false, error: '' };
|
||||||
@@ -18,7 +23,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
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">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-xl font-bold">应用出错</h2>
|
<h2 className="text-xl font-bold">应用出错</h2>
|
||||||
<p className="text-sm opacity-70">{this.state.error}</p>
|
<p className="text-sm opacity-70">{this.state.error}</p>
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ export function TitleBar() {
|
|||||||
className="flex items-center justify-between px-4 py-2 border-b select-none"
|
className="flex items-center justify-between px-4 py-2 border-b select-none"
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
style={{ borderColor: 'var(--app-border)' }}
|
||||||
>
|
>
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">{isAdmin ? t('app.name') : t('app.nameReadonly')}</h1>
|
||||||
{isAdmin ? t('app.name') : t('app.nameReadonly')}
|
|
||||||
</h1>
|
|
||||||
<span className="text-sm opacity-60">v{version}</span>
|
<span className="text-sm opacity-60">v{version}</span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ export function MergePreview() {
|
|||||||
<tr
|
<tr
|
||||||
key={`${source}-${displayIndex}`}
|
key={`${source}-${displayIndex}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor: rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
|
||||||
color: 'var(--app-fg)',
|
color: 'var(--app-fg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const result: PathRow[] = [];
|
const result: PathRow[] = [];
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
const p = paths[i];
|
const p = paths[i];
|
||||||
if (p.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;
|
return result;
|
||||||
}, [paths, searchQuery]);
|
}, [paths, searchQuery]);
|
||||||
@@ -74,15 +75,15 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [paths]);
|
}, [paths]);
|
||||||
|
|
||||||
// 异步展开环境变量(用于 tooltip)
|
// 异步展开环境变量(用于 tooltip)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const toExpand = paths.filter(
|
const toExpand = paths.filter((p) => p.path.includes('%') && !expandedRef.current.has(p.path));
|
||||||
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
|
|
||||||
);
|
|
||||||
if (toExpand.length === 0) return;
|
if (toExpand.length === 0) return;
|
||||||
|
|
||||||
const batch = toExpand.slice(0, 20);
|
const batch = toExpand.slice(0, 20);
|
||||||
@@ -105,7 +106,9 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [paths]);
|
}, [paths]);
|
||||||
|
|
||||||
// 所有路径默认有效(异步验证结果回来后再精确染色)
|
// 所有路径默认有效(异步验证结果回来后再精确染色)
|
||||||
@@ -194,7 +197,10 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
: 'var(--app-list-alt)',
|
: 'var(--app-list-alt)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
<td
|
||||||
|
className="w-8 px-2 py-0.5 text-xs opacity-50"
|
||||||
|
style={{ color: 'var(--app-fg)' }}
|
||||||
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="w-6 px-1 py-0.5">
|
<td className="w-6 px-1 py-0.5">
|
||||||
|
|||||||
@@ -36,12 +36,7 @@ export function ToolBar(props: ToolBarProps) {
|
|||||||
<SearchInput />
|
<SearchInput />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<UndoRedoButtons />
|
<UndoRedoButtons />
|
||||||
<button
|
<button className={btnClass} style={btnStyle} disabled={!isAdmin} onClick={props.onImport}>
|
||||||
className={btnClass}
|
|
||||||
style={btnStyle}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
onClick={props.onImport}
|
|
||||||
>
|
|
||||||
{t('button.import')}
|
{t('button.import')}
|
||||||
</button>
|
</button>
|
||||||
<button className={btnClass} style={btnStyle} onClick={props.onExport}>
|
<button className={btnClass} style={btnStyle} onClick={props.onExport}>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface ModalProps {
|
|||||||
export function Modal({ open, onClose, children }: ModalProps) {
|
export function Modal({ open, onClose, children }: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|||||||
@@ -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 = {
|
export const btnStyle: React.CSSProperties = {
|
||||||
backgroundColor: 'var(--app-bg)',
|
backgroundColor: 'var(--app-bg)',
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function exportToJson(data: ExportData): string {
|
|||||||
const obj = {
|
const obj = {
|
||||||
version,
|
version,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
system: data.system.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 })),
|
user: data.user.map((e) => ({ path: e.path, enabled: e.enabled })),
|
||||||
};
|
};
|
||||||
return JSON.stringify(obj, null, 2);
|
return JSON.stringify(obj, null, 2);
|
||||||
}
|
}
|
||||||
@@ -179,10 +179,14 @@ export function importFromJson(content: string): ImportResult {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(obj.system)) {
|
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)) {
|
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;
|
return result;
|
||||||
@@ -210,10 +214,7 @@ export function importFromTxt(content: string): PathEntry[] {
|
|||||||
|
|
||||||
// ── 自动检测导入 ──
|
// ── 自动检测导入 ──
|
||||||
|
|
||||||
export function importFromContent(
|
export function importFromContent(content: string, filepath: string): ImportResult {
|
||||||
content: string,
|
|
||||||
filepath: string,
|
|
||||||
): ImportResult {
|
|
||||||
const lower = filepath.toLowerCase();
|
const lower = filepath.toLowerCase();
|
||||||
if (lower.endsWith('.csv')) {
|
if (lower.endsWith('.csv')) {
|
||||||
return importFromCsv(content);
|
return importFromCsv(content);
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export function analyzePaths(
|
|||||||
const lower = entry.path.toLowerCase();
|
const lower = entry.path.toLowerCase();
|
||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') });
|
result.push({
|
||||||
|
isValid: validateFn(entry.path),
|
||||||
|
isDuplicate,
|
||||||
|
isEnvVar: entry.path.includes('%'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+31
-7
@@ -5,7 +5,16 @@
|
|||||||
import type { PathEntry } from './path-entry';
|
import type { PathEntry } from './path-entry';
|
||||||
|
|
||||||
export const OperationType = {
|
export const OperationType = {
|
||||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8, 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;
|
} as const;
|
||||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||||
|
|
||||||
@@ -47,7 +56,10 @@ export class UndoRedoManager {
|
|||||||
this.current = this.records.length - 1;
|
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;
|
if (this.current < 0) return null;
|
||||||
|
|
||||||
const rec = this.records[this.current];
|
const rec = this.records[this.current];
|
||||||
@@ -103,7 +115,10 @@ export class UndoRedoManager {
|
|||||||
return [sys, user];
|
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;
|
if (this.current >= this.records.length - 1) return null;
|
||||||
|
|
||||||
this.current++;
|
this.current++;
|
||||||
@@ -159,8 +174,17 @@ export class UndoRedoManager {
|
|||||||
return [sys, user];
|
return [sys, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
canUndo(): boolean { return this.current >= 0; }
|
canUndo(): boolean {
|
||||||
canRedo(): boolean { return this.current < this.records.length - 1; }
|
return this.current >= 0;
|
||||||
clear(): void { this.records = []; this.current = -1; }
|
}
|
||||||
get historyLength(): number { return this.records.length; }
|
canRedo(): boolean {
|
||||||
|
return this.current < this.records.length - 1;
|
||||||
|
}
|
||||||
|
clear(): void {
|
||||||
|
this.records = [];
|
||||||
|
this.current = -1;
|
||||||
|
}
|
||||||
|
get historyLength(): number {
|
||||||
|
return this.records.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { useAppStore } from '@/store/app-store';
|
|||||||
import { TargetType } from '@/core/undo-redo';
|
import { TargetType } from '@/core/undo-redo';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
import {
|
||||||
|
importFromContent,
|
||||||
|
exportToJson,
|
||||||
|
exportToCsv,
|
||||||
|
flattenImportResult,
|
||||||
|
} from '@/core/import-export';
|
||||||
import type { PathEntry } from '@/core/path-entry';
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
import { is_valid_path_format } from '@/core/validation';
|
import { is_valid_path_format } from '@/core/validation';
|
||||||
import { useKeyboard } from './use-keyboard';
|
import { useKeyboard } from './use-keyboard';
|
||||||
@@ -38,7 +43,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
const idx = useAppStore.getState().selectedIndices[0];
|
const idx = useAppStore.getState().selectedIndices[0];
|
||||||
if (idx === undefined) return;
|
if (idx === undefined) return;
|
||||||
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
||||||
const list = target === TargetType.SYSTEM
|
const list =
|
||||||
|
target === TargetType.SYSTEM
|
||||||
? useAppStore.getState().sysPaths
|
? useAppStore.getState().sysPaths
|
||||||
: useAppStore.getState().userPaths;
|
: useAppStore.getState().userPaths;
|
||||||
const entry = list[idx];
|
const entry = list[idx];
|
||||||
@@ -71,14 +77,9 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
}, [getCurrentTarget]);
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
const handleClean = useCallback(() => {
|
const handleClean = useCallback(() => {
|
||||||
const removed = useAppStore.getState().cleanPaths(
|
const removed = useAppStore.getState().cleanPaths(getCurrentTarget(), is_valid_path_format);
|
||||||
getCurrentTarget(),
|
|
||||||
is_valid_path_format,
|
|
||||||
);
|
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
useAppStore.getState().setStatusMessage(
|
useAppStore.getState().setStatusMessage(i18n.t('status.deleted', { count: removed.length }));
|
||||||
i18n.t('status.deleted', { count: removed.length }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [getCurrentTarget]);
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
@@ -95,9 +96,15 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
if (result.system.length > 0 && result.user.length > 0) {
|
if (result.system.length > 0 && result.user.length > 0) {
|
||||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||||
} else if (result.system.length > 0) {
|
} else if (result.system.length > 0) {
|
||||||
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system.map(e => e.path));
|
useAppStore.getState().replacePaths(
|
||||||
|
TargetType.SYSTEM,
|
||||||
|
result.system.map((e) => e.path),
|
||||||
|
);
|
||||||
} else if (result.user.length > 0) {
|
} else if (result.user.length > 0) {
|
||||||
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
|
useAppStore.getState().replacePaths(
|
||||||
|
TargetType.USER,
|
||||||
|
result.user.map((e) => e.path),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [setImportDialog]);
|
}, [setImportDialog]);
|
||||||
|
|
||||||
@@ -148,33 +155,62 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
|
|
||||||
// ── 弹窗确认 ──
|
// ── 弹窗确认 ──
|
||||||
|
|
||||||
const handleNewConfirm = useCallback((value: string) => {
|
const handleNewConfirm = useCallback(
|
||||||
|
(value: string) => {
|
||||||
setNewDialog(false);
|
setNewDialog(false);
|
||||||
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
||||||
}, [getCurrentTarget, setNewDialog]);
|
},
|
||||||
|
[getCurrentTarget, setNewDialog],
|
||||||
|
);
|
||||||
|
|
||||||
const handleEditConfirm = useCallback((value: string) => {
|
const handleEditConfirm = useCallback(
|
||||||
|
(value: string) => {
|
||||||
const d = dialogs.editDialog;
|
const d = dialogs.editDialog;
|
||||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
||||||
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
|
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
|
||||||
}, [dialogs.editDialog, setEditDialog]);
|
},
|
||||||
|
[dialogs.editDialog, setEditDialog],
|
||||||
|
);
|
||||||
|
|
||||||
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
const handleImportSelect = useCallback(
|
||||||
|
(target: 'system' | 'user' | 'both') => {
|
||||||
const { system, user } = dialogs.importDialog;
|
const { system, user } = dialogs.importDialog;
|
||||||
const flat = flattenImportResult({ system, user }, target);
|
const flat = flattenImportResult({ system, user }, target);
|
||||||
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
|
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));
|
useAppStore.getState().replaceBothPaths(
|
||||||
|
flat.system.map((e) => e.path),
|
||||||
|
flat.user.map((e) => e.path),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
|
if (flat.system.length > 0)
|
||||||
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
|
useAppStore.getState().replacePaths(
|
||||||
|
TargetType.SYSTEM,
|
||||||
|
flat.system.map((e) => e.path),
|
||||||
|
);
|
||||||
|
if (flat.user.length > 0)
|
||||||
|
useAppStore.getState().replacePaths(
|
||||||
|
TargetType.USER,
|
||||||
|
flat.user.map((e) => e.path),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setImportDialog({ open: false, system: [], user: [] });
|
setImportDialog({ open: false, system: [], user: [] });
|
||||||
}, [dialogs.importDialog, setImportDialog]);
|
},
|
||||||
|
[dialogs.importDialog, setImportDialog],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleNew, handleEdit, handleBrowse, handleDelete,
|
handleNew,
|
||||||
handleMoveUp, handleMoveDown, handleClean,
|
handleEdit,
|
||||||
handleImport, handleExport, handleSave,
|
handleBrowse,
|
||||||
handleNewConfirm, handleEditConfirm, handleImportSelect,
|
handleDelete,
|
||||||
|
handleMoveUp,
|
||||||
|
handleMoveDown,
|
||||||
|
handleClean,
|
||||||
|
handleImport,
|
||||||
|
handleExport,
|
||||||
|
handleSave,
|
||||||
|
handleNewConfirm,
|
||||||
|
handleEditConfirm,
|
||||||
|
handleImportSelect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
|
|
||||||
|
export type ValidationState = 'valid' | 'invalid' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步验证路径目录是否真实存在 + 展开环境变量
|
||||||
|
* 缓存结果避免重复 IPC 调用。
|
||||||
|
* setState 仅在异步 .then() 回调中调用(符合 React 规则),
|
||||||
|
* 不存在路径的缓存清理通过 useMemo 派生。
|
||||||
|
*/
|
||||||
|
export function usePathValidation(paths: readonly PathEntry[]) {
|
||||||
|
const validatedRef = useRef<Set<string>>(new Set());
|
||||||
|
const expandedRef = useRef<Set<string>>(new Set());
|
||||||
|
const [validationCache, setValidationCache] = useState<Map<string, ValidationState>>(new Map());
|
||||||
|
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// 仅保留当前 paths 中存在的条目(派生 state,不在 effect 中同步 setState)
|
||||||
|
const currentKeys = useMemo(() => new Set(paths.map((p) => p.path)), [paths]);
|
||||||
|
const cleanedValidationCache = useMemo(() => {
|
||||||
|
const next = new Map(validationCache);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of next.keys()) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : validationCache;
|
||||||
|
}, [validationCache, currentKeys]);
|
||||||
|
|
||||||
|
const cleanedExpandedCache = useMemo(() => {
|
||||||
|
const next = new Map(expandedCache);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of next.keys()) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : expandedCache;
|
||||||
|
}, [expandedCache, currentKeys]);
|
||||||
|
|
||||||
|
// 同步清理 ref(ref 不能在 render 期间修改,放在 effect 中不 setState 是安全的)
|
||||||
|
useEffect(() => {
|
||||||
|
for (const key of validatedRef.current) {
|
||||||
|
if (!currentKeys.has(key)) validatedRef.current.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of expandedRef.current) {
|
||||||
|
if (!currentKeys.has(key)) expandedRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}, [currentKeys]);
|
||||||
|
|
||||||
|
// 异步验证路径(setState 在 .then() 回调中,符合 React 规则)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const toValidate = paths.filter((p) => !validatedRef.current.has(p.path));
|
||||||
|
if (toValidate.length === 0) return;
|
||||||
|
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const [p] of results) validatedRef.current.add(p);
|
||||||
|
setValidationCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) next.set(p, v);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths]);
|
||||||
|
|
||||||
|
// 异步展开环境变量(setState 在 .then() 回调中)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
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, ''];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const [p] of results) expandedRef.current.add(p);
|
||||||
|
setExpandedCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) next.set(p, v);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths]);
|
||||||
|
|
||||||
|
return { validationCache: cleanedValidationCache, expandedCache: cleanedExpandedCache };
|
||||||
|
}
|
||||||
+127
-56
@@ -47,11 +47,12 @@ interface AppState {
|
|||||||
loadPaths: () => Promise<void>;
|
loadPaths: () => Promise<void>;
|
||||||
savePaths: () => Promise<void>;
|
savePaths: () => Promise<void>;
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function arraysEqual(a: readonly PathEntry[], b: readonly PathEntry[]): boolean {
|
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) => {
|
export const useAppStore = create<AppState>((set, get) => {
|
||||||
@@ -87,8 +88,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const entry: PathEntry = { path, enabled: true };
|
const entry: PathEntry = { path, enabled: true };
|
||||||
const newList = [...list, entry];
|
const newList = [...list, entry];
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
type: OperationType.ADD,
|
||||||
oldPaths: [], newPaths: [entry],
|
target,
|
||||||
|
index: newList.length - 1,
|
||||||
|
count: 1,
|
||||||
|
oldPaths: [],
|
||||||
|
newPaths: [entry],
|
||||||
});
|
});
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
else set({ userPaths: newList });
|
else set({ userPaths: newList });
|
||||||
@@ -102,8 +107,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
if (!oldEntry) return;
|
if (!oldEntry) return;
|
||||||
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.EDIT, target, index, count: 1,
|
type: OperationType.EDIT,
|
||||||
oldPaths: [oldEntry], newPaths: [newEntry],
|
target,
|
||||||
|
index,
|
||||||
|
count: 1,
|
||||||
|
oldPaths: [oldEntry],
|
||||||
|
newPaths: [newEntry],
|
||||||
});
|
});
|
||||||
const newList = [...list];
|
const newList = [...list];
|
||||||
newList[index] = newEntry;
|
newList[index] = newEntry;
|
||||||
@@ -121,9 +130,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const oldPaths = sortedAsc.map((i) => list[i]);
|
const oldPaths = sortedAsc.map((i) => list[i]);
|
||||||
|
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.DELETE, target,
|
type: OperationType.DELETE,
|
||||||
index: sortedAsc[0], count: sortedAsc.length,
|
target,
|
||||||
oldPaths, newPaths: [],
|
index: sortedAsc[0],
|
||||||
|
count: sortedAsc.length,
|
||||||
|
oldPaths,
|
||||||
|
newPaths: [],
|
||||||
indices: sortedAsc,
|
indices: sortedAsc,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +151,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const state = get();
|
const state = get();
|
||||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.MOVE_UP, target, index, count: 1, oldPaths: [], newPaths: [],
|
type: OperationType.MOVE_UP,
|
||||||
|
target,
|
||||||
|
index,
|
||||||
|
count: 1,
|
||||||
|
oldPaths: [],
|
||||||
|
newPaths: [],
|
||||||
});
|
});
|
||||||
const newList = [...list];
|
const newList = [...list];
|
||||||
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||||
@@ -153,7 +170,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
if (index >= list.length - 1) return;
|
if (index >= list.length - 1) return;
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.MOVE_DOWN, target, index, count: 1, oldPaths: [], newPaths: [],
|
type: OperationType.MOVE_DOWN,
|
||||||
|
target,
|
||||||
|
index,
|
||||||
|
count: 1,
|
||||||
|
oldPaths: [],
|
||||||
|
newPaths: [],
|
||||||
});
|
});
|
||||||
const newList = [...list];
|
const newList = [...list];
|
||||||
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||||
@@ -169,26 +191,34 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
|
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.CLEAN, target, index: 0, count: removed.length,
|
type: OperationType.CLEAN,
|
||||||
oldPaths: [...list], newPaths: kept,
|
target,
|
||||||
|
index: 0,
|
||||||
|
count: removed.length,
|
||||||
|
oldPaths: [...list],
|
||||||
|
newPaths: kept,
|
||||||
});
|
});
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
||||||
else set({ userPaths: kept, selectedIndices: [] });
|
else set({ userPaths: kept, selectedIndices: [] });
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed.map(e => e.path);
|
return removed.map((e) => e.path);
|
||||||
},
|
},
|
||||||
|
|
||||||
replacePaths: (target, newPaths) => {
|
replacePaths: (target, newPaths) => {
|
||||||
if (newPaths.length === 0) return;
|
if (newPaths.length === 0) return;
|
||||||
const state = get();
|
const state = get();
|
||||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
const entries: PathEntry[] = newPaths.map(p => ({ path: p, enabled: true }));
|
const entries: PathEntry[] = newPaths.map((p) => ({ path: p, enabled: true }));
|
||||||
|
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.IMPORT, target, index: 0, count: entries.length,
|
type: OperationType.IMPORT,
|
||||||
oldPaths: [...list], newPaths: [...entries],
|
target,
|
||||||
|
index: 0,
|
||||||
|
count: entries.length,
|
||||||
|
oldPaths: [...list],
|
||||||
|
newPaths: [...entries],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
||||||
@@ -198,13 +228,17 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
|
|
||||||
replaceBothPaths: (sysPaths, userPaths) => {
|
replaceBothPaths: (sysPaths, userPaths) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const sysEntries: PathEntry[] = sysPaths.map(p => ({ path: p, enabled: true }));
|
const sysEntries: PathEntry[] = sysPaths.map((p) => ({ path: p, enabled: true }));
|
||||||
const usrEntries: PathEntry[] = userPaths.map(p => ({ path: p, enabled: true }));
|
const usrEntries: PathEntry[] = userPaths.map((p) => ({ path: p, enabled: true }));
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.IMPORT_BOTH, target: TargetType.SYSTEM, index: 0,
|
type: OperationType.IMPORT_BOTH,
|
||||||
|
target: TargetType.SYSTEM,
|
||||||
|
index: 0,
|
||||||
count: sysEntries.length + usrEntries.length,
|
count: sysEntries.length + usrEntries.length,
|
||||||
oldPaths: [...state.sysPaths], newPaths: [...sysEntries],
|
oldPaths: [...state.sysPaths],
|
||||||
oldPathsOther: [...state.userPaths], newPathsOther: [...usrEntries],
|
newPaths: [...sysEntries],
|
||||||
|
oldPathsOther: [...state.userPaths],
|
||||||
|
newPathsOther: [...usrEntries],
|
||||||
});
|
});
|
||||||
set({ sysPaths: [...sysEntries], userPaths: [...usrEntries], selectedIndices: [] });
|
set({ sysPaths: [...sysEntries], userPaths: [...usrEntries], selectedIndices: [] });
|
||||||
markDirty();
|
markDirty();
|
||||||
@@ -216,8 +250,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
|
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.CLEAR, target, index: 0, count: list.length,
|
type: OperationType.CLEAR,
|
||||||
oldPaths: [...list], newPaths: [],
|
target,
|
||||||
|
index: 0,
|
||||||
|
count: list.length,
|
||||||
|
oldPaths: [...list],
|
||||||
|
newPaths: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
||||||
@@ -233,8 +271,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
||||||
|
|
||||||
state.undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.TOGGLE, target, index, count: 1,
|
type: OperationType.TOGGLE,
|
||||||
oldPaths: [oldEntry], newPaths: [newEntry],
|
target,
|
||||||
|
index,
|
||||||
|
count: 1,
|
||||||
|
oldPaths: [oldEntry],
|
||||||
|
newPaths: [newEntry],
|
||||||
});
|
});
|
||||||
|
|
||||||
const newList = [...list];
|
const newList = [...list];
|
||||||
@@ -245,10 +287,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
|
|
||||||
// 即时保存禁用状态
|
// 即时保存禁用状态
|
||||||
const { sysPaths: sys, userPaths: usr } = get();
|
const { sysPaths: sys, userPaths: usr } = get();
|
||||||
const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path);
|
const sysDisabled = sys.filter((e) => !e.enabled).map((e) => e.path);
|
||||||
const usrDisabled = usr.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 })
|
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }).catch(() => {});
|
||||||
.catch(() => {});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
undo: () => {
|
undo: () => {
|
||||||
@@ -256,14 +297,16 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const result = undoRedo.undo(sysPaths, userPaths);
|
const result = undoRedo.undo(sysPaths, userPaths);
|
||||||
if (result) {
|
if (result) {
|
||||||
set({
|
set({
|
||||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
sysPaths: result[0],
|
||||||
|
userPaths: result[1],
|
||||||
|
selectedIndices: [],
|
||||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
});
|
});
|
||||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||||
invoke('save_disabled_state', {
|
invoke('save_disabled_state', {
|
||||||
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
system: result[0].filter((e) => !e.enabled).map((e) => e.path),
|
||||||
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
user: result[1].filter((e) => !e.enabled).map((e) => e.path),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -273,14 +316,16 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const result = undoRedo.redo(sysPaths, userPaths);
|
const result = undoRedo.redo(sysPaths, userPaths);
|
||||||
if (result) {
|
if (result) {
|
||||||
set({
|
set({
|
||||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
sysPaths: result[0],
|
||||||
|
userPaths: result[1],
|
||||||
|
selectedIndices: [],
|
||||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
});
|
});
|
||||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||||
invoke('save_disabled_state', {
|
invoke('save_disabled_state', {
|
||||||
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
system: result[0].filter((e) => !e.enabled).map((e) => e.path),
|
||||||
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
user: result[1].filter((e) => !e.enabled).map((e) => e.path),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -307,15 +352,21 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const sysSet = new Set(sysDisabled);
|
const sysSet = new Set(sysDisabled);
|
||||||
const usrSet = new Set(usrDisabled);
|
const usrSet = new Set(usrDisabled);
|
||||||
|
|
||||||
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysSet.has(p) }));
|
const sysEntries: PathEntry[] = sysArr.map((p) => ({ path: p, enabled: !sysSet.has(p) }));
|
||||||
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrSet.has(p) }));
|
const usrEntries: PathEntry[] = userArr.map((p) => ({ path: p, enabled: !usrSet.has(p) }));
|
||||||
|
|
||||||
set({
|
set({
|
||||||
sysPaths: sysEntries, userPaths: usrEntries,
|
sysPaths: sysEntries,
|
||||||
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
|
userPaths: usrEntries,
|
||||||
|
_savedSys: [...sysEntries],
|
||||||
|
_savedUser: [...usrEntries],
|
||||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
isLoading: false, isModified: false,
|
isLoading: false,
|
||||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
isModified: false,
|
||||||
|
statusMessage: i18n.t('status.loaded', {
|
||||||
|
sysCount: sysArr.length,
|
||||||
|
userCount: userArr.length,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||||||
@@ -328,20 +379,28 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||||
|
|
||||||
// 只保存 enabled 的路径到注册表
|
// 只保存 enabled 的路径到注册表
|
||||||
const sysPaths = state.sysPaths.filter(e => e.enabled).map(e => e.path);
|
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 userPaths = state.userPaths.filter((e) => e.enabled).map((e) => e.path);
|
||||||
const sysJoined = sysPaths.join(';');
|
const sysJoined = sysPaths.join(';');
|
||||||
const userJoined = userPaths.join(';');
|
const userJoined = userPaths.join(';');
|
||||||
|
|
||||||
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
||||||
if (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength) {
|
if (
|
||||||
if (!window.confirm('PATH 长度超过建议值,是否继续保存?')) { set({ isSaving: false }); return; }
|
sysJoined.length > maxSystemLength ||
|
||||||
|
userJoined.length > maxUserLength ||
|
||||||
|
(sysJoined + userJoined).length > maxCombinedLength
|
||||||
|
) {
|
||||||
|
if (!window.confirm('PATH 长度超过建议值,是否继续保存?')) {
|
||||||
|
set({ isSaving: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||||
let backupFailed = false;
|
let backupFailed = false;
|
||||||
await invoke('backup_registry', { customDir: null })
|
await invoke('backup_registry', { customDir: null }).catch(() => {
|
||||||
.catch(() => { backupFailed = true; });
|
backupFailed = true;
|
||||||
|
});
|
||||||
|
|
||||||
const [sysResult, userResult] = await Promise.allSettled([
|
const [sysResult, userResult] = await Promise.allSettled([
|
||||||
invoke('save_system_paths', { paths: sysPaths }),
|
invoke('save_system_paths', { paths: sysPaths }),
|
||||||
@@ -353,15 +412,26 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
|
|
||||||
if (sysOk && userOk) {
|
if (sysOk && userOk) {
|
||||||
invoke('broadcast_env_change').catch(() => {});
|
invoke('broadcast_env_change').catch(() => {});
|
||||||
const savedSys = [...state.sysPaths], savedUser = [...state.userPaths];
|
const savedSys = [...state.sysPaths],
|
||||||
set({ isModified: false, isSaving: false,
|
savedUser = [...state.userPaths];
|
||||||
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
|
set({
|
||||||
_savedSys: savedSys, _savedUser: savedUser });
|
isModified: false,
|
||||||
|
isSaving: false,
|
||||||
|
statusMessage: backupFailed
|
||||||
|
? i18n.t('status.saved_without_backup')
|
||||||
|
: i18n.t('status.saved'),
|
||||||
|
_savedSys: savedSys,
|
||||||
|
_savedUser: savedUser,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
const sysErr = !sysOk && sysResult.status === 'rejected' ? String(sysResult.reason) : '';
|
||||||
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
const usrErr = !userOk && userResult.status === 'rejected' ? String(userResult.reason) : '';
|
||||||
const parts = [sysErr, usrErr].filter(Boolean);
|
const parts = [sysErr, usrErr].filter(Boolean);
|
||||||
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`;
|
const msg = sysOk
|
||||||
|
? '用户 PATH 保存失败'
|
||||||
|
: userOk
|
||||||
|
? '系统 PATH 保存失败'
|
||||||
|
: `保存失败: ${parts.join('; ')}`;
|
||||||
set({ isSaving: false, statusMessage: msg });
|
set({ isSaving: false, statusMessage: msg });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -376,4 +446,5 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
}
|
}
|
||||||
await get().loadPaths();
|
await get().loadPaths();
|
||||||
},
|
},
|
||||||
};});
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ vi.mock('@/i18n', () => ({
|
|||||||
|
|
||||||
describe('AnalyzeDialog', () => {
|
describe('AnalyzeDialog', () => {
|
||||||
it('渲染冲突检测和工具清单标签页,不崩溃', () => {
|
it('渲染冲突检测和工具清单标签页,不崩溃', () => {
|
||||||
const { container } = render(
|
const { container } = render(<AnalyzeDialog open={true} onClose={() => {}} />);
|
||||||
<AnalyzeDialog open={true} onClose={() => {}} />,
|
|
||||||
);
|
|
||||||
const text = container.textContent || '';
|
const text = container.textContent || '';
|
||||||
expect(text).toContain('analyze.conflicts');
|
expect(text).toContain('analyze.conflicts');
|
||||||
expect(text).toContain('analyze.tools');
|
expect(text).toContain('analyze.tools');
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ vi.mock('@tauri-apps/api/core', () => ({
|
|||||||
|
|
||||||
// Mock i18n
|
// Mock i18n
|
||||||
vi.mock('@/i18n', () => ({
|
vi.mock('@/i18n', () => ({
|
||||||
default: { t: vi.fn((key: string, opts?: Record<string, unknown>) => {
|
default: {
|
||||||
if (key === 'status.loaded') return `已加载 ${opts?.sysCount} 条系统 PATH,${opts?.userCount} 条用户 PATH`;
|
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.error') return '加载失败';
|
||||||
if (key === 'status.saving') return '正在保存...';
|
if (key === 'status.saving') return '正在保存...';
|
||||||
if (key === 'status.saved') return '保存成功';
|
if (key === 'status.saved') return '保存成功';
|
||||||
@@ -16,7 +18,8 @@ vi.mock('@/i18n', () => ({
|
|||||||
if (key === 'status.readonly') return '只读模式';
|
if (key === 'status.readonly') return '只读模式';
|
||||||
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
|
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
|
||||||
return key;
|
return key;
|
||||||
}) },
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { PathEntry } from '../../src/core/path-entry';
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
@@ -56,7 +59,7 @@ describe('app-store CRUD', () => {
|
|||||||
it('addPath 追加到 sysPaths', () => {
|
it('addPath 追加到 sysPaths', () => {
|
||||||
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
|
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\test']);
|
expect(s.sysPaths.map((e) => e.path)).toEqual(['C:\\test']);
|
||||||
expect(s.isModified).toBe(true);
|
expect(s.isModified).toBe(true);
|
||||||
expect(s.undoRedo.historyLength).toBe(1);
|
expect(s.undoRedo.historyLength).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -64,7 +67,7 @@ describe('app-store CRUD', () => {
|
|||||||
it('addPath 追加到 userPaths', () => {
|
it('addPath 追加到 userPaths', () => {
|
||||||
useAppStore.getState().addPath('D:\\user', TargetType.USER);
|
useAppStore.getState().addPath('D:\\user', TargetType.USER);
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\user']);
|
expect(s.userPaths.map((e) => e.path)).toEqual(['D:\\user']);
|
||||||
expect(s.sysPaths).toEqual([]);
|
expect(s.sysPaths).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ describe('app-store CRUD', () => {
|
|||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('C:\\old', TargetType.SYSTEM);
|
store.addPath('C:\\old', TargetType.SYSTEM);
|
||||||
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
|
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\new']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\new']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('editPath 越界 index 无崩溃', () => {
|
it('editPath 越界 index 无崩溃', () => {
|
||||||
@@ -87,7 +90,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('B', TargetType.SYSTEM);
|
store.addPath('B', TargetType.SYSTEM);
|
||||||
store.addPath('C', TargetType.SYSTEM);
|
store.addPath('C', TargetType.SYSTEM);
|
||||||
store.deletePaths([1], TargetType.SYSTEM);
|
store.deletePaths([1], TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A', 'C']);
|
||||||
expect(useAppStore.getState().selectedIndices).toEqual([]);
|
expect(useAppStore.getState().selectedIndices).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('C', TargetType.USER);
|
store.addPath('C', TargetType.USER);
|
||||||
store.addPath('D', TargetType.USER);
|
store.addPath('D', TargetType.USER);
|
||||||
store.deletePaths([1, 3], TargetType.USER);
|
store.deletePaths([1, 3], TargetType.USER);
|
||||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A', 'C']);
|
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['A', 'C']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
||||||
@@ -108,16 +111,16 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('C', TargetType.SYSTEM);
|
store.addPath('C', TargetType.SYSTEM);
|
||||||
store.addPath('D', TargetType.SYSTEM);
|
store.addPath('D', TargetType.SYSTEM);
|
||||||
store.deletePaths([1, 3], TargetType.SYSTEM);
|
store.deletePaths([1, 3], TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A', 'C']);
|
||||||
useAppStore.getState().undo();
|
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 无操作', () => {
|
it('moveUp index=0 无操作', () => {
|
||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('A', TargetType.SYSTEM);
|
store.addPath('A', TargetType.SYSTEM);
|
||||||
store.moveUp(0, TargetType.SYSTEM);
|
store.moveUp(0, TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['A']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moveUp 正常交换位置', () => {
|
it('moveUp 正常交换位置', () => {
|
||||||
@@ -125,7 +128,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('A', TargetType.SYSTEM);
|
store.addPath('A', TargetType.SYSTEM);
|
||||||
store.addPath('B', TargetType.SYSTEM);
|
store.addPath('B', TargetType.SYSTEM);
|
||||||
store.moveUp(1, TargetType.SYSTEM);
|
store.moveUp(1, TargetType.SYSTEM);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['B', 'A']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['B', 'A']);
|
||||||
expect(useAppStore.getState().selectedIndices).toEqual([0]);
|
expect(useAppStore.getState().selectedIndices).toEqual([0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +136,7 @@ describe('app-store CRUD', () => {
|
|||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
store.addPath('A', TargetType.USER);
|
store.addPath('A', TargetType.USER);
|
||||||
store.moveDown(0, TargetType.USER);
|
store.moveDown(0, TargetType.USER);
|
||||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A']);
|
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['A']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleanPaths 移除无效路径并返回 removed', () => {
|
it('cleanPaths 移除无效路径并返回 removed', () => {
|
||||||
@@ -143,7 +146,7 @@ describe('app-store CRUD', () => {
|
|||||||
// is_valid_path_format 拒绝全标点路径
|
// is_valid_path_format 拒绝全标点路径
|
||||||
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
|
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
|
||||||
expect(removed).toEqual([':::invalid:::']);
|
expect(removed).toEqual([':::invalid:::']);
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\valid']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['C:\\valid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replacePaths 整体替换列表', () => {
|
it('replacePaths 整体替换列表', () => {
|
||||||
@@ -151,7 +154,7 @@ describe('app-store CRUD', () => {
|
|||||||
store.addPath('old1', TargetType.USER);
|
store.addPath('old1', TargetType.USER);
|
||||||
store.addPath('old2', TargetType.USER);
|
store.addPath('old2', TargetType.USER);
|
||||||
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
||||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['new1', 'new2', 'new3']);
|
expect(useAppStore.getState().userPaths.map((e) => e.path)).toEqual(['new1', 'new2', 'new3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearPaths 清空列表', () => {
|
it('clearPaths 清空列表', () => {
|
||||||
@@ -187,7 +190,7 @@ describe('undo/redo', () => {
|
|||||||
store.addPath('test', TargetType.SYSTEM);
|
store.addPath('test', TargetType.SYSTEM);
|
||||||
store.undo();
|
store.undo();
|
||||||
store.redo();
|
store.redo();
|
||||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['test']);
|
expect(useAppStore.getState().sysPaths.map((e) => e.path)).toEqual(['test']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('undo/redo 正确更新 isModified', () => {
|
it('undo/redo 正确更新 isModified', () => {
|
||||||
@@ -214,8 +217,8 @@ describe('loadPaths', () => {
|
|||||||
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
|
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
|
||||||
await useAppStore.getState().loadPaths();
|
await useAppStore.getState().loadPaths();
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
|
expect(s.sysPaths.map((e) => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
|
||||||
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\usr1']);
|
expect(s.userPaths.map((e) => e.path)).toEqual(['D:\\usr1']);
|
||||||
expect(s.isLoading).toBe(false);
|
expect(s.isLoading).toBe(false);
|
||||||
expect(s.isModified).toBe(false);
|
expect(s.isModified).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -259,7 +262,9 @@ describe('savePaths', () => {
|
|||||||
|
|
||||||
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
||||||
let resolveAll: (v: unknown) => void;
|
let resolveAll: (v: unknown) => void;
|
||||||
const pending = new Promise((r) => { resolveAll = r; });
|
const pending = new Promise((r) => {
|
||||||
|
resolveAll = r;
|
||||||
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
mockedInvoke.mockReturnValue(pending as any);
|
mockedInvoke.mockReturnValue(pending as any);
|
||||||
|
|
||||||
@@ -291,8 +296,8 @@ describe('initialize', () => {
|
|||||||
await useAppStore.getState().initialize();
|
await useAppStore.getState().initialize();
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.isAdmin).toBe(true);
|
expect(s.isAdmin).toBe(true);
|
||||||
expect(s.sysPaths.map(e => e.path)).toEqual(['S1']);
|
expect(s.sysPaths.map((e) => e.path)).toEqual(['S1']);
|
||||||
expect(s.userPaths.map(e => e.path)).toEqual(['U1']);
|
expect(s.userPaths.map((e) => e.path)).toEqual(['U1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('非管理员初始化进入只读模式', async () => {
|
it('非管理员初始化进入只读模式', async () => {
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ describe('exportToJson', () => {
|
|||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
expect(parsed.version).toBe('5.0.0');
|
expect(parsed.version).toBe('5.0.0');
|
||||||
expect(parsed.timestamp).toBeDefined();
|
expect(parsed.timestamp).toBeDefined();
|
||||||
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(sampleData.system.map(e => e.path));
|
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(
|
||||||
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(sampleData.user.map(e => e.path));
|
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.system[0].enabled).toBe(true);
|
||||||
expect(parsed.user[0].enabled).toBe(true);
|
expect(parsed.user[0].enabled).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -36,8 +40,8 @@ describe('exportToJson', () => {
|
|||||||
describe('importFromJson', () => {
|
describe('importFromJson', () => {
|
||||||
it('正确导入 JSON', () => {
|
it('正确导入 JSON', () => {
|
||||||
const json = JSON.stringify({
|
const json = JSON.stringify({
|
||||||
system: sampleData.system.map(e => e.path),
|
system: sampleData.system.map((e) => e.path),
|
||||||
user: sampleData.user.map(e => e.path),
|
user: sampleData.user.map((e) => e.path),
|
||||||
});
|
});
|
||||||
const result = importFromJson(json);
|
const result = importFromJson(json);
|
||||||
expect(result.system).toEqual(sampleData.system);
|
expect(result.system).toEqual(sampleData.system);
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ describe('导入一致性(TS 端)', () => {
|
|||||||
it('JSON 含 system + user', () => {
|
it('JSON 含 system + user', () => {
|
||||||
const json = JSON.stringify({ system: ['C:\\a', 'C:\\b'], user: ['D:\\c'] });
|
const json = JSON.stringify({ system: ['C:\\a', 'C:\\b'], user: ['D:\\c'] });
|
||||||
const r = importFromJson(json);
|
const r = importFromJson(json);
|
||||||
expect(r.system.map(e => e.path)).toEqual(['C:\\a', 'C:\\b']);
|
expect(r.system.map((e) => e.path)).toEqual(['C:\\a', 'C:\\b']);
|
||||||
expect(r.user.map(e => e.path)).toEqual(['D:\\c']);
|
expect(r.user.map((e) => e.path)).toEqual(['D:\\c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CSV system/user 分类', () => {
|
it('CSV system/user 分类', () => {
|
||||||
const csv = 'type,path\nsystem,C:\\sys\nuser,D:\\usr\n';
|
const csv = 'type,path\nsystem,C:\\sys\nuser,D:\\usr\n';
|
||||||
const r = importFromCsv(csv);
|
const r = importFromCsv(csv);
|
||||||
expect(r.system.map(e => e.path)).toEqual(['C:\\sys']);
|
expect(r.system.map((e) => e.path)).toEqual(['C:\\sys']);
|
||||||
expect(r.user.map(e => e.path)).toEqual(['D:\\usr']);
|
expect(r.user.map((e) => e.path)).toEqual(['D:\\usr']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CSV 含 BOM + header', () => {
|
it('CSV 含 BOM + header', () => {
|
||||||
const csv = 'type,path\nsystem,C:\\x\n';
|
const csv = 'type,path\nsystem,C:\\x\n';
|
||||||
const r = importFromCsv(csv);
|
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 逐行读取,跳过注释', () => {
|
it('TXT 逐行读取,跳过注释', () => {
|
||||||
const txt = '# comment\nC:\\a\n\nD:\\b\n';
|
const txt = '# comment\nC:\\a\n\nD:\\b\n';
|
||||||
const r = importFromTxt(txt);
|
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 空数据不崩溃', () => {
|
it('JSON 空数据不崩溃', () => {
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ vi.mock('@/store/app-store', () => ({
|
|||||||
{ path: 'C:\\Windows', enabled: true },
|
{ path: 'C:\\Windows', enabled: true },
|
||||||
{ path: 'C:\\Disabled', enabled: false },
|
{ path: 'C:\\Disabled', enabled: false },
|
||||||
],
|
],
|
||||||
userPaths: [
|
userPaths: [{ path: 'D:\\UserApp', enabled: true }],
|
||||||
{ path: 'D:\\UserApp', enabled: true },
|
|
||||||
],
|
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
};
|
};
|
||||||
return selector(state);
|
return selector(state);
|
||||||
|
|||||||
@@ -11,20 +11,26 @@ const validateFn = (path: string) => !path.includes('Invalid');
|
|||||||
|
|
||||||
describe('pathClean', () => {
|
describe('pathClean', () => {
|
||||||
it('移除无效路径', () => {
|
it('移除无效路径', () => {
|
||||||
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')], validateFn);
|
const [kept, removed] = pathClean(
|
||||||
expect(kept.map(e => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
|
[pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')],
|
||||||
expect(removed.map(e => e.path)).toEqual(['C:\\Invalid']);
|
validateFn,
|
||||||
|
);
|
||||||
|
expect(kept.map((e) => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||||
|
expect(removed.map((e) => e.path)).toEqual(['C:\\Invalid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('移除重复路径保留第一个', () => {
|
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(kept.length).toBe(2);
|
||||||
expect(removed.length).toBe(1);
|
expect(removed.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('全部有效无变化', () => {
|
it('全部有效无变化', () => {
|
||||||
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
|
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);
|
expect(removed.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { UndoRedoManager, OperationType, TargetType, type OpRecord } from '../../src/core/undo-redo';
|
import {
|
||||||
|
UndoRedoManager,
|
||||||
|
OperationType,
|
||||||
|
TargetType,
|
||||||
|
type OpRecord,
|
||||||
|
} from '../../src/core/undo-redo';
|
||||||
import type { PathEntry } from '../../src/core/path-entry';
|
import type { PathEntry } from '../../src/core/path-entry';
|
||||||
|
|
||||||
function pe(s: string, enabled: boolean = true): PathEntry {
|
function pe(s: string, enabled: boolean = true): PathEntry {
|
||||||
return { path: s, enabled };
|
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 };
|
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')]));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0].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)!;
|
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 撤销/重做', () => {
|
it('DELETE 撤销/重做', () => {
|
||||||
@@ -46,11 +58,20 @@ describe('UndoRedoManager', () => {
|
|||||||
expect(u[0][0].path).toBe(removed.path);
|
expect(u[0][0].path).toBe(removed.path);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
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 撤销/重做', () => {
|
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');
|
sys[0] = pe('C:\\Edited');
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
@@ -65,10 +86,10 @@ describe('UndoRedoManager', () => {
|
|||||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0].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)!;
|
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 撤销/重做', () => {
|
it('MOVE_DOWN 撤销/重做', () => {
|
||||||
@@ -76,7 +97,7 @@ describe('UndoRedoManager', () => {
|
|||||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0].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 撤销/重做', () => {
|
it('CLEAN 撤销/重做', () => {
|
||||||
@@ -140,9 +161,12 @@ describe('UndoRedoManager', () => {
|
|||||||
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
||||||
const removed = [sys[1], sys[3]];
|
const removed = [sys[1], sys[3]];
|
||||||
mgr.push({
|
mgr.push({
|
||||||
type: OperationType.DELETE, target: TargetType.SYSTEM,
|
type: OperationType.DELETE,
|
||||||
index: 1, count: 2,
|
target: TargetType.SYSTEM,
|
||||||
oldPaths: removed, newPaths: [],
|
index: 1,
|
||||||
|
count: 2,
|
||||||
|
oldPaths: removed,
|
||||||
|
newPaths: [],
|
||||||
indices: [1, 3],
|
indices: [1, 3],
|
||||||
});
|
});
|
||||||
sys.splice(3, 1);
|
sys.splice(3, 1);
|
||||||
@@ -152,21 +176,29 @@ describe('UndoRedoManager', () => {
|
|||||||
expect(u[0]).toEqual(old);
|
expect(u[0]).toEqual(old);
|
||||||
|
|
||||||
const r = mgr.redo(...u)!;
|
const r = mgr.redo(...u)!;
|
||||||
expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
expect(r[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('操作 USER 路径', () => {
|
it('操作 USER 路径', () => {
|
||||||
user.push(pe('C:\\NewUserPath'));
|
user.push(pe('C:\\NewUserPath'));
|
||||||
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
|
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[1].map(e => e.path)).toEqual(['C:\\Users\\me\\AppData']);
|
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[0].map((e) => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOGGLE 撤销/重做', () => {
|
it('TOGGLE 撤销/重做', () => {
|
||||||
sys[0] = pe('C:\\Windows', false);
|
sys[0] = pe('C:\\Windows', false);
|
||||||
mgr.push(makeRecord(OperationType.TOGGLE, TargetType.SYSTEM, 0, 1,
|
mgr.push(
|
||||||
[pe('C:\\Windows', true)], [pe('C:\\Windows', false)]));
|
makeRecord(
|
||||||
|
OperationType.TOGGLE,
|
||||||
|
TargetType.SYSTEM,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
[pe('C:\\Windows', true)],
|
||||||
|
[pe('C:\\Windows', false)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const u = mgr.undo(sys, user)!;
|
const u = mgr.undo(sys, user)!;
|
||||||
expect(u[0][0].enabled).toBe(true);
|
expect(u[0][0].enabled).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// ── Mock 外部依赖 ──
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({
|
||||||
|
invoke: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOpen = vi.fn().mockResolvedValue(null);
|
||||||
|
const mockAsk = vi.fn().mockResolvedValue(true);
|
||||||
|
vi.mock('@tauri-apps/plugin-dialog', () => ({
|
||||||
|
open: (...args: unknown[]) => mockOpen(...args),
|
||||||
|
ask: (...args: unknown[]) => mockAsk(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-keyboard', () => ({
|
||||||
|
useKeyboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { UndoRedoManager, TargetType } from '@/core/undo-redo';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
|
|
||||||
|
const mockedInvoke = vi.mocked(invoke);
|
||||||
|
|
||||||
|
function pe(s: string, enabled = true): PathEntry {
|
||||||
|
return { path: s, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStore(sys: PathEntry[] = [], user: PathEntry[] = []) {
|
||||||
|
useAppStore.setState({
|
||||||
|
sysPaths: sys,
|
||||||
|
userPaths: user,
|
||||||
|
undoRedo: new UndoRedoManager(50),
|
||||||
|
_savedSys: sys,
|
||||||
|
_savedUser: user,
|
||||||
|
selectedIndices: [],
|
||||||
|
isModified: false,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
isAdmin: true,
|
||||||
|
statusMessage: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// useAppActions 需要 DialogState,创建一个 mock
|
||||||
|
function mockDialogs() {
|
||||||
|
return {
|
||||||
|
editDialog: { open: false, index: -1, value: '', target: TargetType.SYSTEM },
|
||||||
|
newDialog: false,
|
||||||
|
helpOpen: false,
|
||||||
|
importDialog: { open: false, system: [] as PathEntry[], user: [] as PathEntry[] },
|
||||||
|
setEditDialog: vi.fn(),
|
||||||
|
setNewDialog: vi.fn(),
|
||||||
|
setHelpOpen: vi.fn(),
|
||||||
|
setImportDialog: vi.fn(),
|
||||||
|
setAnalyzeOpen: vi.fn(),
|
||||||
|
setProfilesOpen: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAppActions', () => {
|
||||||
|
let dialogs: ReturnType<typeof mockDialogs>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetStore([pe('C:\\Windows'), pe('C:\\Program Files')], [pe('D:\\User')]);
|
||||||
|
dialogs = mockDialogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleNew ──
|
||||||
|
|
||||||
|
it('handleNew 打开新建弹窗', async () => {
|
||||||
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNew();
|
||||||
|
});
|
||||||
|
expect(dialogs.setNewDialog).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleEdit ──
|
||||||
|
|
||||||
|
it('handleEdit 打开编辑弹窗(有选中项)', async () => {
|
||||||
|
useAppStore.setState({ selectedIndices: [0] });
|
||||||
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
|
act(() => {
|
||||||
|
result.current.handleEdit();
|
||||||
|
});
|
||||||
|
expect(dialogs.setEditDialog).toHaveBeenCalledWith({
|
||||||
|
open: true,
|
||||||
|
index: 0,
|
||||||
|
value: 'C:\\Windows',
|
||||||
|
target: TargetType.SYSTEM,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleEdit 无选中项不操作', async () => {
|
||||||
|
useAppStore.setState({ selectedIndices: [] });
|
||||||
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
|
act(() => {
|
||||||
|
result.current.handleEdit();
|
||||||
|
});
|
||||||
|
expect(dialogs.setEditDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleDelete ──
|
||||||
|
|
||||||
|
it('handleDelete 删除选中项', async () => {
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleDelete 无选中项不操作', async () => {
|
||||||
|
useAppStore.setState({ selectedIndices: [] });
|
||||||
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
|
act(() => {
|
||||||
|
result.current.handleDelete();
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().sysPaths.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleMoveUp / handleMoveDown ──
|
||||||
|
|
||||||
|
it('handleMoveUp 上移选中项', async () => {
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleClean ──
|
||||||
|
|
||||||
|
it('handleClean 清理无效路径', async () => {
|
||||||
|
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']);
|
||||||
|
expect(useAppStore.getState().statusMessage).toContain('已删除 1 条');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleNewConfirm ──
|
||||||
|
|
||||||
|
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');
|
||||||
|
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(' ');
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().sysPaths.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleEditConfirm ──
|
||||||
|
|
||||||
|
it('handleEditConfirm 修改路径', async () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().sysPaths[0].path).toBe('C:\\Edited');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handleImportSelect ──
|
||||||
|
|
||||||
|
it('handleImportSelect both 模式替换双 hive', async () => {
|
||||||
|
const sysImport = [pe('C:\\ImportSys')];
|
||||||
|
const usrImport = [pe('D:\\ImportUsr')];
|
||||||
|
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']);
|
||||||
|
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')],
|
||||||
|
};
|
||||||
|
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']); // 未变
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
});
|
||||||
|
// savePaths is called
|
||||||
|
expect(useAppStore.getState().savePaths).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleSave 超长确认后强制保存', async () => {
|
||||||
|
// 第一次 savePaths 返回 warning(超长)
|
||||||
|
// 第二次(force=true)返回 success
|
||||||
|
let callCount = 0;
|
||||||
|
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async (force?: boolean) => {
|
||||||
|
callCount++;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "e2e"]
|
||||||
|
}
|
||||||
@@ -9,5 +9,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
|
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov', 'cobertura'],
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: ['src/main.tsx', 'src/vite-env.d.ts', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||||
|
thresholds: {
|
||||||
|
branches: 60,
|
||||||
|
functions: 60,
|
||||||
|
lines: 70,
|
||||||
|
statements: 70,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user