Compare commits

..

20 Commits

Author SHA1 Message Date
Serendipity c1975e836c chore: 移除调试文件,更新 .gitignore
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:34:23 +08:00
Serendipity be04b7d0da fix: 修复 ESLint 错误 — path-manager 测试去 as any、search-clean 未用参数
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:26:56 +08:00
Serendipity 2b372cbf89 chore: 添加 vitest.config.ts — 排除 e2e 测试目录,配置 @/ 路径别名
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:18:42 +08:00
Serendipity 45e2a4e584 test: 新增 4 条 E2E 测试 — 启动加载、CRUD撤销、禁用保存、搜索清理 2026-05-27 14:16:24 +08:00
Serendipity ff343185c9 chore: 安装 Playwright + 配置 E2E 基础框架
- 安装 @playwright/test 1.60.0
- 创建 e2e/playwright.config.ts(webServer 自动启动 vite dev)
- 创建 e2e/mocks/ipc.ts(Tauri IPC mock)
- 新增 npm run test:e2e 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:01:47 +08:00
Serendipity 6d711d0f8e test: 修复所有测试适配 PathEntry,全部通过
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:59:40 +08:00
Serendipity d6e535aa98 feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:56:52 +08:00
Serendipity e646a84291 feat: app-store 适配 PathEntry — 新增 togglePath、loadPaths 合并禁用状态、savePaths 过滤 disabled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:49:26 +08:00
Serendipity 611a36fb98 refactor: core 模块适配 PathEntry — path-manager、import-export 类型迁移
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:45:05 +08:00
Serendipity ab2d0da20c feat: 新增 disabled.rs — 禁用路径 JSON 文件读写
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:44:24 +08:00
Serendipity 914b25f236 test: 所有测试适配 PathEntry[] 类型,新增 TOGGLE undo/redo 测试
- undo-redo.test.ts: 所有 11 项测试通过(含新增 TOGGLE 撤销/重做)
- app-store.test.ts: 断言改用 .map(e => e.path),待 Task 5 修复
- import-export.test.ts: sampleData 改用 pe(),导出断言适配
- path-manager.test.ts: 测试数据用 pe() 包裹,待 Task 3 修复
- validation.test.ts: 无需变更(纯 string 接口)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:42:21 +08:00
Serendipity 32287c0e4b feat: 新增 PathEntry 类型 + TOGGLE 操作类型,undo-redo 用 PathEntry[] 替代 string[]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:32:35 +08:00
Serendipity 71b98e308a docs: 添加 v4.3 路径启用/禁用 + E2E 测试实现计划(10 个 Task)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:21:35 +08:00
Serendipity fcd4796fee docs: 添加 v4.3 路径启用/禁用 + E2E 测试设计文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:19:02 +08:00
Serendipity 8ff02fd88b fix: 修复 ESLint 错误 — PathEditDialog/use-keyboard/test 添加规则豁免注释
这些是正当的 React 模式(对话框状态重置、ref 同步避免重复注册、测试 mock)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:07:57 +08:00
Serendipity 39a95cc50d fix: CI 切换到 MSVC 工具链、添加 eslint.config.js
- Rust: windows-latest 默认 MSVC,无需额外安装 GNU/MinGW
- ESLint: 添加 eslint flat config(js + tseslint + react-hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:58:30 +08:00
Serendipity 44fdc2eec6 fix: release 改用 gh release create、ci 添加 permissions: contents: read
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:54:07 +08:00
Serendipity 6dc32dca93 ci: 添加 CI workflow — push 自动检查 TypeScript + Rust
前端: tsc --noEmit + ESLint + Vitest (ubuntu)
Rust: cargo check + clippy + test (windows + GNU toolchain)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:52:13 +08:00
Serendipity a2b66d087f ci: 添加 Release workflow — tag 推送自动构建 NSIS 安装包并发布
tag v* 触发 npx tauri build,NSIS 安装包上传到 GitHub Release

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