mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:45:54 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1975e836c | |||
| be04b7d0da | |||
| 2b372cbf89 | |||
| 45e2a4e584 | |||
| ff343185c9 | |||
| 6d711d0f8e | |||
| d6e535aa98 | |||
| e646a84291 | |||
| 611a36fb98 | |||
| ab2d0da20c | |||
| 914b25f236 | |||
| 32287c0e4b | |||
| 71b98e308a | |||
| fcd4796fee | |||
| 8ff02fd88b | |||
| 39a95cc50d | |||
| 44fdc2eec6 | |||
| 6dc32dca93 | |||
| a2b66d087f | |||
| 8c1655d25c | |||
| 63c8ed424b | |||
| 605105da09 | |||
| 68f4617bda | |||
| c30855fa70 | |||
| 652280c2dd | |||
| 2db872c661 | |||
| 1ce3ebfd9e | |||
| 613fb51fd7 | |||
| be375ed3ad | |||
| 804e02004d | |||
| 2775a3a588 | |||
| 26ab52483a | |||
| 775a570d31 |
@@ -0,0 +1,52 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: 前端检查 (TypeScript + Lint + Test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: TypeScript 类型检查
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Vitest 测试
|
||||
run: npm test
|
||||
|
||||
rust:
|
||||
name: Rust 检查 (Check + Clippy + Test)
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cargo Check
|
||||
run: cargo check
|
||||
|
||||
- name: Cargo Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Cargo Test
|
||||
run: cargo test
|
||||
@@ -0,0 +1,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 }}
|
||||
@@ -24,3 +24,5 @@ dist-ssr
|
||||
*.sw?
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
e2e/debug-screenshot.png
|
||||
test-results/
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
# v4.1 Bug 修复与代码清理 — 实现计划
|
||||
|
||||
> **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:** 修复 3 个 bug + 7 个代码质量问题
|
||||
|
||||
**Architecture:** 集中在 core/undo-redo.ts、store/app-store.ts、Rust commands/backup.rs、PathTable.tsx、import-export.ts。改动互不冲突,按依赖排序。
|
||||
|
||||
**Tech Stack:** TypeScript strict + Rust + Tauri IPC + Vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: B1 — OpRecord 新增 indices 字段 + undo/redo 修复
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/undo-redo.ts:13-20, 56-60, 97-101`
|
||||
- Modify: `tests/unit/undo-redo.test.ts` (新增测试)
|
||||
|
||||
- [ ] **Step 1: 更新 OpRecord 接口和 undo/redo 逻辑**
|
||||
|
||||
```typescript
|
||||
// src/core/undo-redo.ts — 修改 OpRecord 接口(第 13-20 行替换)
|
||||
export interface OpRecord {
|
||||
type: OperationType;
|
||||
target: TargetType;
|
||||
index: number;
|
||||
count: number;
|
||||
oldPaths: string[];
|
||||
newPaths: string[];
|
||||
/** DELETE 操作专用:被删除的各路径的原始 index(已排序) */
|
||||
indices?: number[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 DELETE 的 undo 逻辑**
|
||||
|
||||
```typescript
|
||||
// src/core/undo-redo.ts — 替换第 56-60 行
|
||||
case OperationType.DELETE:
|
||||
if (rec.indices) {
|
||||
// 精确恢复到原始位置
|
||||
for (let i = 0; i < rec.indices.length; i++) {
|
||||
target.splice(rec.indices[i], 0, rec.oldPaths[i]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.splice(rec.index + i, 0, rec.oldPaths[i]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 DELETE 的 redo 逻辑**
|
||||
|
||||
```typescript
|
||||
// src/core/undo-redo.ts — 替换第 97-101 行
|
||||
case OperationType.DELETE:
|
||||
if (rec.indices) {
|
||||
for (let i = rec.indices.length - 1; i >= 0; i--) {
|
||||
target.splice(rec.indices[i], 1);
|
||||
}
|
||||
} else {
|
||||
for (let i = rec.count - 1; i >= 0; i--) {
|
||||
target.splice(rec.index + i, 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增非连续删除 undo/redo 测试**
|
||||
|
||||
```typescript
|
||||
// tests/unit/undo-redo.test.ts — 在最后一个 it() 之后、闭合 }); 之前插入
|
||||
|
||||
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
|
||||
const old = [...sys, 'C:\\Extra1', 'C:\\Extra2'];
|
||||
sys = old;
|
||||
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
||||
const removed = [sys[1], sys[3]];
|
||||
mgr.push(makeRecord(OperationType.DELETE, TargetType.SYSTEM, 1, 2, removed, []));
|
||||
sys.splice(3, 1);
|
||||
sys.splice(1, 1);
|
||||
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(old);
|
||||
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/undo-redo.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/core/undo-redo.ts tests/unit/undo-redo.test.ts
|
||||
git commit -m "fix: 非连续删除 undo 恢复到错误位置 — OpRecord 新增 indices 精确记录原始位置"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: B1 — app-store deletePaths 传入 indices
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/store/app-store.ts:104-123`
|
||||
- Modify: `tests/unit/app-store.test.ts` (新增测试)
|
||||
|
||||
- [ ] **Step 1: deletePaths 传入 sorted indices**
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 替换第 104-123 行
|
||||
deletePaths: (indices, target) => {
|
||||
if (indices.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const sorted = [...indices].sort((a, b) => b - a);
|
||||
const sortedAsc = [...indices].sort((a, b) => a - b);
|
||||
const oldPaths = sortedAsc.map((i) => list[i]);
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.DELETE, target,
|
||||
index: sortedAsc[0], count: sortedAsc.length,
|
||||
oldPaths, newPaths: [],
|
||||
indices: sortedAsc,
|
||||
});
|
||||
|
||||
const toRemove = new Set(sorted);
|
||||
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
|
||||
else set({ userPaths: newList, selectedIndices: [] });
|
||||
get()._markDirty();
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增非连续多选删除测试**
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 在 "deletePaths 多选删除" 测试后插入
|
||||
|
||||
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.addPath('B', TargetType.SYSTEM);
|
||||
store.addPath('C', TargetType.SYSTEM);
|
||||
store.addPath('D', TargetType.SYSTEM);
|
||||
store.deletePaths([1, 3], TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths).toEqual(['A', 'C']);
|
||||
// undo 应恢复到原始顺序
|
||||
useAppStore.getState().undo();
|
||||
expect(useAppStore.getState().sysPaths).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/app-store.test.ts tests/unit/undo-redo.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/store/app-store.ts tests/unit/app-store.test.ts
|
||||
git commit -m "fix: deletePaths 传入 indices 数组以支持非连续多选删除的精确 undo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: B2 — Rust 端 backup_registry 内部读取注册表
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/commands/registry.rs:8, 21, 66-80` (改可见性)
|
||||
- Modify: `src-tauri/src/commands/backup.rs:22-56` (重写函数签名)
|
||||
|
||||
- [ ] **Step 1: 将 split_path / join_path 改为 pub(crate)**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 第 8 行,fn load_paths 也改为 pub(crate)
|
||||
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
```
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 第 21 行
|
||||
pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
|
||||
```
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 第 66 行
|
||||
pub(crate) fn split_path(raw: &str) -> Vec<String> {
|
||||
```
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 第 73 行
|
||||
pub(crate) fn join_path(paths: &[String]) -> String {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 backup_registry**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/backup.rs — 替换整个 backup_registry 函数(第 22-56 行)
|
||||
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
||||
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
|
||||
#[tauri::command]
|
||||
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
||||
use crate::commands::registry;
|
||||
use winreg::enums::*;
|
||||
|
||||
let backup_dir = match custom_dir {
|
||||
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
|
||||
_ => backup_base_dir(),
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&backup_dir)
|
||||
.map_err(|e| format!("无法创建备份目录: {}", e))?;
|
||||
|
||||
// 读取当前注册表中的值
|
||||
let sys_paths = registry::load_paths(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||
"系统",
|
||||
)?;
|
||||
let user_paths = registry::load_paths(
|
||||
HKEY_CURRENT_USER,
|
||||
"Environment",
|
||||
"用户",
|
||||
)?;
|
||||
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S_%3f");
|
||||
let filename = format!("path_backup_{}.txt", timestamp);
|
||||
let filepath = backup_dir.join(&filename);
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str(&format!(
|
||||
"PathEditor Backup - {}\n",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
|
||||
));
|
||||
content.push_str("\n[System PATH]\n");
|
||||
for path in &sys_paths {
|
||||
content.push_str(&format!("{}\n", path));
|
||||
}
|
||||
content.push_str("\n[User PATH]\n");
|
||||
for path in &user_paths {
|
||||
content.push_str(&format!("{}\n", path));
|
||||
}
|
||||
|
||||
std::fs::write(&filepath, &content)
|
||||
.map_err(|e| format!("无法写入备份文件: {}", e))?;
|
||||
|
||||
let result = filepath.to_string_lossy().to_string();
|
||||
log::info!("备份已保存到: {}", result);
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/registry.rs src-tauri/src/commands/backup.rs
|
||||
git commit -m "fix: backup_registry 改为内部读取注册表当前值,不再依赖前端传入数据"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: B2 — 更新前端 savePaths 和 lib.rs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/lib.rs:27` (移除旧的参数)
|
||||
- Modify: `src/store/app-store.ts:261-263` (简化调用)
|
||||
- Modify: `tests/unit/app-store.test.ts:244-280` (更新 mock)
|
||||
|
||||
- [ ] **Step 1: lib.rs 无需修改(命令签名更新后自动适配)**
|
||||
|
||||
lib.rs 中 `commands::backup::backup_registry` 注册已存在,函数签名变更后自动适配。
|
||||
|
||||
- [ ] **Step 2: 简化前端 savePaths 中的 backup 调用**
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 替换第 261-263 行
|
||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||
invoke('backup_registry', { customDir: null })
|
||||
.catch(() => set({ statusMessage: i18n.t('status.warning_backup') }));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 app-store 测试中的 mock**
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 修改 "保存成功" 测试中的 mock(第 244-251 行)
|
||||
it('保存成功', async () => {
|
||||
mockedInvoke.mockResolvedValue(undefined);
|
||||
await useAppStore.getState().savePaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isSaving).toBe(false);
|
||||
expect(s.isModified).toBe(false);
|
||||
expect(s.statusMessage).toBe('保存成功');
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 修改 "部分失败" 测试中的 mock(第 253-262 行)
|
||||
it('部分失败时报告具体 hive', async () => {
|
||||
mockedInvoke
|
||||
.mockResolvedValueOnce(undefined) // backup_registry(现在无参数)
|
||||
.mockResolvedValueOnce(undefined) // save_system_paths
|
||||
.mockRejectedValueOnce('权限不足'); // save_user_paths
|
||||
await useAppStore.getState().savePaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isSaving).toBe(false);
|
||||
expect(s.statusMessage).toContain('用户 PATH 保存失败');
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 修改 "isSaving 守卫" 测试中的 mock(第 264-280 行)
|
||||
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
||||
let resolveAll: (v: unknown) => void;
|
||||
const pending = new Promise((r) => { resolveAll = r; });
|
||||
mockedInvoke.mockReturnValue(pending as any);
|
||||
|
||||
const p1 = useAppStore.getState().savePaths();
|
||||
const r2 = useAppStore.getState().savePaths();
|
||||
|
||||
await expect(r2).resolves.toBeUndefined();
|
||||
|
||||
resolveAll!(undefined);
|
||||
await p1;
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/app-store.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/store/app-store.ts tests/unit/app-store.test.ts
|
||||
git commit -m "fix: 前端 backup 调用不再传递 paths,由 Rust 端自行读取注册表"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: B3 — 验证异常返回"未知"而非"有效"
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/path-list/PathTable.tsx:26-27, 59-61, 119-120`
|
||||
|
||||
- [ ] **Step 1: 改缓存类型和异常处理**
|
||||
|
||||
```typescript
|
||||
// src/components/path-list/PathTable.tsx — 替换第 26-27 行
|
||||
type ValidationState = 'valid' | 'invalid' | 'unknown';
|
||||
const [validationCache, setValidationCache] = useState<Map<string, ValidationState>>(new Map());
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/components/path-list/PathTable.tsx — 替换第 54-62 行
|
||||
const batch = toValidate.slice(0, 20);
|
||||
Promise.all(
|
||||
batch.map(async (p): Promise<[string, ValidationState]> => {
|
||||
try {
|
||||
if (p.includes('%')) return [p, 'valid'];
|
||||
const valid: boolean = await invoke('validate_path', { path: p });
|
||||
return [p, valid ? 'valid' : 'invalid'];
|
||||
} catch {
|
||||
return [p, 'unknown'];
|
||||
}
|
||||
}),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 UI 渲染逻辑**
|
||||
|
||||
```typescript
|
||||
// src/components/path-list/PathTable.tsx — 替换第 112-125 行的 validations useMemo
|
||||
const validations = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return filtered.map(({ path }) => {
|
||||
const lower = path.toLowerCase();
|
||||
const isDuplicate = seen.has(lower);
|
||||
seen.add(lower);
|
||||
return {
|
||||
state: validationCache.get(path) ?? 'valid' as ValidationState,
|
||||
isDuplicate,
|
||||
isEnvVar: path.includes('%'),
|
||||
};
|
||||
});
|
||||
}, [filtered, validationCache]);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/components/path-list/PathTable.tsx — 替换第 170-173 行的颜色逻辑
|
||||
const v = validations[rowIdx];
|
||||
const isSelected = selectedIndices.includes(index);
|
||||
let textColor = 'var(--app-fg)';
|
||||
if (v.state === 'invalid') textColor = '#dc3545';
|
||||
else if (v.isDuplicate) textColor = '#fd7e14';
|
||||
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 类型检查**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/path-list/PathTable.tsx
|
||||
git commit -m "fix: 验证 IPC 异常时返回 unknown 状态,不再错误标记为有效路径"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: C1 — 删除 AppError 死代码
|
||||
|
||||
**Files:**
|
||||
- Delete: `src-tauri/src/error.rs`
|
||||
- Modify: `src-tauri/src/lib.rs:2`
|
||||
|
||||
- [ ] **Step 1: 从 lib.rs 移除 mod error**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/lib.rs — 删除第 2 行,第 1 行保留
|
||||
mod commands;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删除 error.rs 文件**
|
||||
|
||||
```bash
|
||||
rm src-tauri/src/error.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/lib.rs src-tauri/src/error.rs
|
||||
git commit -m "refactor: 删除未使用的 AppError 死代码"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: C2 — importPaths 重命名为 replacePaths
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/store/app-store.ts:37, 171-183`
|
||||
- Modify: `src/hooks/use-app-actions.ts:95, 97, 162, 163`
|
||||
- Modify: `tests/unit/app-store.test.ts:131-137`
|
||||
|
||||
- [ ] **Step 1: app-store.ts 重命名**
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 第 37 行
|
||||
replacePaths: (target: TargetType, newPaths: string[]) => void;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 替换第 171-183 行
|
||||
replacePaths: (target, newPaths) => {
|
||||
if (newPaths.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT, target, index: 0, count: newPaths.length,
|
||||
oldPaths: [...list], newPaths: [...newPaths],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...newPaths], selectedIndices: [] });
|
||||
else set({ userPaths: [...newPaths], selectedIndices: [] });
|
||||
get()._markDirty();
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: use-app-actions.ts 更新所有调用**
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-app-actions.ts — 替换第 95 行
|
||||
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-app-actions.ts — 替换第 97 行
|
||||
useAppStore.getState().replacePaths(TargetType.USER, result.user);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-app-actions.ts — 替换第 162 行
|
||||
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-app-actions.ts — 替换第 163 行
|
||||
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新测试**
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 替换第 131-137 行
|
||||
it('replacePaths 整体替换列表', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('old1', TargetType.USER);
|
||||
store.addPath('old2', TargetType.USER);
|
||||
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
||||
expect(useAppStore.getState().userPaths).toEqual(['new1', 'new2', 'new3']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 类型检查和测试**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/store/app-store.ts src/hooks/use-app-actions.ts tests/unit/app-store.test.ts
|
||||
git commit -m "refactor: importPaths 重命名为 replacePaths,反映全量替换语义"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: C3 — detectExportFormat 修正
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/import-export.ts:5, 13-16`
|
||||
- Modify: `tests/unit/import-export.test.ts:115-124`
|
||||
|
||||
- [ ] **Step 1: 改类型和函数**
|
||||
|
||||
```typescript
|
||||
// src/core/import-export.ts — 第 5 行
|
||||
export type ExportFormat = 'json' | 'csv' | 'txt';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/core/import-export.ts — 替换第 13-16 行
|
||||
export function detectExportFormat(filepath: string): ExportFormat {
|
||||
const lower = filepath.toLowerCase();
|
||||
if (lower.endsWith('.csv')) return 'csv';
|
||||
if (lower.endsWith('.txt')) return 'txt';
|
||||
return 'json';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新测试**
|
||||
|
||||
```typescript
|
||||
// tests/unit/import-export.test.ts — 替换第 115-124 行
|
||||
describe('detectExportFormat', () => {
|
||||
it('.csv 检测为 CSV', () => {
|
||||
expect(detectExportFormat('data.CSV')).toBe('csv');
|
||||
});
|
||||
|
||||
it('.txt 检测为 TXT', () => {
|
||||
expect(detectExportFormat('data.txt')).toBe('txt');
|
||||
});
|
||||
|
||||
it('其他扩展名检测为 JSON', () => {
|
||||
expect(detectExportFormat('data.json')).toBe('json');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/import-export.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/core/import-export.ts tests/unit/import-export.test.ts
|
||||
git commit -m "fix: detectExportFormat 对 .txt 返回 'txt' 而非 'json'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: C4 — _markDirty 私有化
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/store/app-store.ts:47, 223-226` (移除接口定义,改为闭包私有函数)
|
||||
- Modify: `tests/unit/app-store.test.ts:188-208` (删除 _markDirty 测试小节)
|
||||
|
||||
- [ ] **Step 1: 从接口移除 _markDirty,改为闭包内私有函数**
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 删除第 47 行
|
||||
// (从 AppState 接口中删除 _markDirty: () => void; 这一行)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 在 create() 之前插入模块级私有函数
|
||||
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 删除第 223-226 行的 _markDirty 实现,替换为 create() 外部的私有函数
|
||||
|
||||
// 在 create() 调用之前插入(arraysEqual 后面):
|
||||
const _markDirty = (get: () => AppState, set: (partial: Partial<AppState>) => void) => {
|
||||
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
|
||||
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 替换所有 get()._markDirty() 调用为 _markDirty(get, set)
|
||||
// 例如第 85 行: _markDirty(get, set);
|
||||
// 例如第 101 行: _markDirty(get, set);
|
||||
// 等等(共 8 处)
|
||||
```
|
||||
|
||||
等等,这个改法会让每个 CRUD 方法的参数变复杂。更简洁的做法是用闭包捕获:
|
||||
|
||||
更好的做法:把 `_markDirty` 放在 `create()` 内部、`return` 之前,作为一个局部函数,所有 CRUD 方法通过闭包访问它。
|
||||
|
||||
```typescript
|
||||
// src/store/app-store.ts — 整体结构变为:
|
||||
export const useAppStore = create<AppState>((set, get) => {
|
||||
// 私有函数,不暴露到 store 接口
|
||||
const markDirty = () => {
|
||||
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
|
||||
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
|
||||
};
|
||||
|
||||
return {
|
||||
// ... 所有状态和方法,内部调用 markDirty() 而非 get()._markDirty()
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 app-store.ts 为闭包私有 markDirty**
|
||||
|
||||
完整改动涉及将 `create<AppState>((set, get) => ({...}))` 改为 `create<AppState>((set, get) => { const markDirty = ...; return {...}; })`。
|
||||
|
||||
具体:移除 `AppState` 接口中的 `_markDirty`,删除第 223-226 行的实现,在 create 回调函数体顶部定义 `markDirty` 局部函数,将所有 8 处 `get()._markDirty()` 替换为 `markDirty()`。
|
||||
|
||||
- [ ] **Step 3: 删除测试中的 _markDirty 小节**
|
||||
|
||||
```typescript
|
||||
// tests/unit/app-store.test.ts — 删除第 188-208 行(整个 describe('_markDirty', ...) 块)
|
||||
```
|
||||
|
||||
`_markDirty` 的行为通过 CRUD 测试中的 `isModified` 断言间接覆盖。
|
||||
|
||||
- [ ] **Step 4: 类型检查和测试**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/store/app-store.ts tests/unit/app-store.test.ts
|
||||
git commit -m "refactor: _markDirty 改为 store 闭包内私有函数,不暴露到公共接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: C5 — PATH 长度阈值统一
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/default.json:15-17`
|
||||
|
||||
- [ ] **Step 1: 更新阈值**
|
||||
|
||||
```json
|
||||
// src/config/default.json — 替换第 15-17 行
|
||||
"maxSystemLength": 32767,
|
||||
"maxUserLength": 32767,
|
||||
"maxCombinedLength": 32767
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/config/default.json
|
||||
git commit -m "fix: 前端 PATH 长度阈值与 Rust 端统一为 32767 字符"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: O1 — BOM 只在首行检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/import-export.ts:68-74, 177-179`
|
||||
|
||||
- [ ] **Step 1: importFromCsv 首行 BOM 处理**
|
||||
|
||||
```typescript
|
||||
// src/core/import-export.ts — 替换第 62-97 行(整个 importFromCsv 函数)
|
||||
export function importFromCsv(content: string): ImportResult {
|
||||
const result: ImportResult = { system: [], user: [] };
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
let hasHeader = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
// BOM 仅出现在第一行
|
||||
if (i === 0 && line.startsWith('')) {
|
||||
line = line.slice(1);
|
||||
}
|
||||
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
const fields = parseCsvLine(line);
|
||||
if (fields.length < 2) continue;
|
||||
|
||||
if (!hasHeader && isHeaderRow(fields[0], fields[1])) {
|
||||
hasHeader = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = fields[0].trim().toLowerCase();
|
||||
const path = fields[1].trim();
|
||||
|
||||
if (path.length === 0) continue;
|
||||
|
||||
if (type === 'system') {
|
||||
result.system.push(path);
|
||||
} else if (type === 'user') {
|
||||
result.user.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: importFromTxt 首行 BOM 处理**
|
||||
|
||||
```typescript
|
||||
// src/core/import-export.ts — 替换第 173-188 行
|
||||
export function importFromTxt(content: string): string[] {
|
||||
const paths: string[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
if (i === 0 && line.startsWith('')) {
|
||||
line = line.slice(1);
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
|
||||
paths.push(trimmed);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/import-export.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/core/import-export.ts
|
||||
git commit -m "perf: BOM 检查从每行移到仅首行"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: O2 — split_path 同步注释
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/commands/registry.rs:66`
|
||||
- Modify: `src/core/validation.ts:30`
|
||||
|
||||
- [ ] **Step 1: 两边加注释**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 在 split_path 函数上方加一行
|
||||
/// 将分号分隔的 PATH 字符串拆分为数组。
|
||||
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
|
||||
pub(crate) fn split_path(raw: &str) -> Vec<String> {
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/core/validation.ts — 在 split_path 函数上方加一行
|
||||
/** 分割 PATH 字符串。
|
||||
* 注意:Rust 端 src-tauri/src/commands/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
|
||||
export function split_path(raw: string): string[] {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/registry.rs src/core/validation.ts
|
||||
git commit -m "docs: split_path 添加同步提醒注释(Rust + TS 双端实现)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行说明
|
||||
|
||||
按 Task 1→12 顺序执行,每个 Task 内 Step 按序执行。Task 之间互有依赖(app-store.ts 被 Task 2、4、7、9 修改),顺序不能乱。
|
||||
|
||||
全部完成后运行完整测试:
|
||||
```bash
|
||||
npx vitest run && cd src-tauri && cargo test && cargo clippy -- -D warnings
|
||||
```
|
||||
@@ -0,0 +1,294 @@
|
||||
# v4.1 第二轮代码清理 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 修复第二轮深度审查发现的 7 个代码质量问题
|
||||
|
||||
**Architecture:** 7 个独立小修,互不冲突。PathTable.tsx 有 3 项相关改动,放在一个 Task 里。
|
||||
|
||||
**Tech Stack:** TypeScript strict + React + Rust + Tauri IPC
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PathTable 三合一(并发限制 + 双重触发 + 类型断言)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/path-list/PathTable.tsx:25, 53, 78, 88-96, 111, 121`
|
||||
|
||||
- [ ] **Step 1: 提取 `DEFAULT_VALIDATION_STATE` 常量,消除类型断言**
|
||||
|
||||
```typescript
|
||||
// 在组件外部定义(文件顶部 import 之后)
|
||||
const DEFAULT_VALIDATION_STATE: ValidationState = 'valid';
|
||||
```
|
||||
|
||||
第 121 行改为:
|
||||
```typescript
|
||||
state: validationCache.get(path) ?? DEFAULT_VALIDATION_STATE,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 环境变量展开加 20 并发上限**
|
||||
|
||||
第 88-96 行,在 `toExpand` 之后加批次限制:
|
||||
```typescript
|
||||
const toExpand = paths.filter(
|
||||
(p) => p.includes('%') && !expandedCache.has(p),
|
||||
);
|
||||
if (toExpand.length === 0) return;
|
||||
|
||||
const batch = toExpand.slice(0, 20); // ← 新增:限制并发 20
|
||||
Promise.all(
|
||||
batch.map(async (p): Promise<[string, string]> => {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 消除 useEffect 双重触发**
|
||||
|
||||
问题:`validationCache` 和 `expandedCache` 在依赖数组中,setState 后触发 effect 再次执行(空跑一轮)。
|
||||
|
||||
用 ref 跟踪"是否已经触发过验证",effect 只依赖 `paths`:
|
||||
|
||||
```typescript
|
||||
// 新增两个 ref(放在 useState 声明之后)
|
||||
const validatedRef = useRef<Set<string>>(new Set());
|
||||
const expandedRef = useRef<Set<string>>(new Set());
|
||||
```
|
||||
|
||||
验证 effect 改为:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toValidate = paths.filter((p) => !validatedRef.current.has(p));
|
||||
if (toValidate.length === 0) return;
|
||||
|
||||
const batch = toValidate.slice(0, 20);
|
||||
Promise.all(
|
||||
batch.map(async (p): Promise<[string, ValidationState]> => {
|
||||
try {
|
||||
if (p.includes('%')) return [p, 'valid'];
|
||||
const valid: boolean = await invoke('validate_path', { path: p });
|
||||
return [p, valid ? 'valid' : 'invalid'];
|
||||
} catch {
|
||||
return [p, '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]); // ← 移除 validationCache 依赖
|
||||
```
|
||||
|
||||
展开 effect 同理:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toExpand = paths.filter((p) => p.includes('%') && !expandedRef.current.has(p));
|
||||
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 });
|
||||
return [p, expanded !== p ? expanded : ''];
|
||||
} catch {
|
||||
return [p, ''];
|
||||
}
|
||||
}),
|
||||
).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]); // ← 移除 expandedCache 依赖
|
||||
```
|
||||
|
||||
`validations` 和渲染逻辑不变。
|
||||
|
||||
- [ ] **Step 4: 添加 `useRef` 到 import**
|
||||
|
||||
```typescript
|
||||
// 第 1 行
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译 + 测试**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/path-list/PathTable.tsx
|
||||
git commit -m "fix: PathTable — 环境变量展开限流20并发、消除useEffect双重触发、类型断言改为常量"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: backup.rs — use 语句移到文件顶部 + 消除路径字符串重复
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/commands/registry.rs:4-5`
|
||||
- Modify: `src-tauri/src/commands/backup.rs:1-3, 22-23, 34-42`
|
||||
|
||||
- [ ] **Step 1: registry.rs 常量改为 pub(crate)**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/registry.rs — 第 4-5 行,加 pub(crate)
|
||||
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
||||
pub(crate) const USER_REG_PATH: &str = "Environment";
|
||||
pub(crate) const PATH_VALUE: &str = "Path";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: backup.rs — 移动 use 到文件顶部,消除重复字符串**
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/backup.rs — 文件顶部(第 1 行之后)
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
use winreg::enums::*; // ← 从函数体内移出
|
||||
|
||||
// 第 4 行之后新增:
|
||||
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
|
||||
```
|
||||
|
||||
删除函数体内的 `use`(第 22-23 行),更新路径引用:
|
||||
|
||||
```rust
|
||||
// backup.rs — 第 34-42 行,用常量替换字符串字面量
|
||||
let sys_paths = registry::load_paths(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
SYS_REG_PATH, // ← 用常量
|
||||
"系统",
|
||||
)?;
|
||||
let user_paths = registry::load_paths(
|
||||
HKEY_CURRENT_USER,
|
||||
USER_REG_PATH, // ← 用常量
|
||||
"用户",
|
||||
)?;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译 + clippy**
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check && cargo clippy -- -D warnings
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/registry.rs src-tauri/src/commands/backup.rs
|
||||
git commit -m "refactor: backup.rs — use 语句移至文件顶部,注册表路径复用常量消除重复"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: AppShell — `as any` 拖拽路径类型安全
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/layout/AppShell.tsx:95`
|
||||
|
||||
- [ ] **Step 1: 定义 TauriFile 接口并消除 as any**
|
||||
|
||||
在 AppShell 组件定义之前(第 16 行之后)加:
|
||||
|
||||
```typescript
|
||||
/** Tauri 的 File 对象扩展了标准 File,额外提供文件系统路径 */
|
||||
interface TauriFile extends File {
|
||||
path: string;
|
||||
}
|
||||
```
|
||||
|
||||
第 95 行改为:
|
||||
```typescript
|
||||
const file = e.dataTransfer.files[i] as TauriFile;
|
||||
if (file.path) useAppStore.getState().addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/layout/AppShell.tsx
|
||||
git commit -m "fix: AppShell 拖拽路径消除 as any,用 TauriFile 接口类型安全访问 path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: app-store — undo/redo 加注释说明为何不用 markDirty()
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/store/app-store.ts:210-226`
|
||||
|
||||
- [ ] **Step 1: 在 undo/redo 的 isModified 行添加注释**
|
||||
|
||||
```typescript
|
||||
// app-store.ts — 第 213 行之前加注释
|
||||
undo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.undo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联 isModified 计算而非调用 markDirty(),避免两次 set() 渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.redo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联 isModified 计算而非调用 markDirty(),避免两次 set() 渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/store/app-store.ts
|
||||
git commit -m "docs: undo/redo 添加注释说明为何内联 isModified 而非调用 markDirty()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
Task 1 → 2 → 3 → 4,互不依赖但建议按序执行。
|
||||
|
||||
全部完成后运行完整验证:
|
||||
```bash
|
||||
npx tsc --noEmit && npx vitest run && cd src-tauri && cargo check && cargo clippy -- -D warnings
|
||||
```
|
||||
@@ -0,0 +1,212 @@
|
||||
# v4.2 CI/CD 流水线 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 PathEditor 添加 GitHub Actions CI/CD:push 自动检查 + tag 自动构建发布
|
||||
|
||||
**Architecture:** 两个 workflow 文件。前端 job 跑 ubuntu(快),Rust job 跑 windows(winreg 依赖)。tag 推送触发 NSIS 构建上传。
|
||||
|
||||
**Tech Stack:** GitHub Actions, Windows runner, MinGW (MSYS2), Tauri CLI
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 创建 CI workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: 创建目录并写入 ci.yml**
|
||||
|
||||
```bash
|
||||
mkdir -p .github/workflows
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: 前端检查 (TypeScript + Lint + Test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: TypeScript 类型检查
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Vitest 测试
|
||||
run: npm test
|
||||
|
||||
rust:
|
||||
name: Rust 检查 (Check + Clippy + Test)
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 GNU 工具链
|
||||
run: |
|
||||
rustup toolchain install stable-x86_64-pc-windows-gnu
|
||||
rustup override set stable-x86_64-pc-windows-gnu
|
||||
|
||||
- name: 添加 MinGW 到 PATH
|
||||
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
|
||||
- name: Cargo Check
|
||||
run: cargo check
|
||||
|
||||
- name: Cargo Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Cargo Test
|
||||
run: cargo test
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 本地验证 YAML 语法**
|
||||
|
||||
```bash
|
||||
# 可以用 Python 验证 YAML 语法(可选)
|
||||
python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" 2>/dev/null || echo "跳过(无需本地验证,push 后 GitHub 自行检查)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "ci: 添加 CI workflow — push 自动检查 TypeScript + Rust
|
||||
|
||||
前端: tsc --noEmit + ESLint + Vitest (ubuntu)
|
||||
Rust: cargo check + clippy + test (windows + GNU toolchain)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建 Release workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/release.yml`
|
||||
|
||||
- [ ] **Step 1: 写入 release.yml**
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: 构建 NSIS 安装包并发布
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: 安装 GNU 工具链
|
||||
run: |
|
||||
rustup toolchain install stable-x86_64-pc-windows-gnu
|
||||
rustup override set stable-x86_64-pc-windows-gnu
|
||||
|
||||
- name: 添加 MinGW 到 PATH
|
||||
run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
|
||||
- name: Tauri Build
|
||||
run: npx tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: 上传安装包到 Release
|
||||
run: |
|
||||
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
|
||||
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/release.yml
|
||||
git commit -m "ci: 添加 Release workflow — tag 推送自动构建 NSIS 安装包并发布
|
||||
|
||||
tag v* 触发 Tauri build,生成 NSIS 安装包后上传到 GitHub Release
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 推送并验证
|
||||
|
||||
- [ ] **Step 1: 推送到 GitHub**
|
||||
|
||||
```bash
|
||||
git push origin v4.2
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 查看 GitHub Actions**
|
||||
|
||||
打开 `https://github.com/LHY0125/PathEditor/actions`,确认 CI workflow 已触发并等待结果。
|
||||
|
||||
两个 job 应该都绿:
|
||||
- `前端检查` — tsc + lint + vitest 通过
|
||||
- `Rust 检查` — check + clippy + test 通过
|
||||
|
||||
- [ ] **Step 3: 验证 Release workflow(可选)**
|
||||
|
||||
推送一个测试 tag:
|
||||
```bash
|
||||
git tag -a v4.2.0-beta -m "测试 CI release"
|
||||
git push origin v4.2.0-beta
|
||||
```
|
||||
|
||||
确认 `https://github.com/LHY0125/PathEditor/releases` 出现构建产物。测试完成后删除 tag:
|
||||
```bash
|
||||
git push origin --delete v4.2.0-beta
|
||||
git tag -d v4.2.0-beta
|
||||
gh release delete v4.2.0-beta --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **TAURI_SIGNING_PRIVATE_KEY**: 如果项目签名配置了 Tauri updater 密钥,需要在 GitHub Settings → Secrets 中添加这两个 secret。如果当前没有配置 updater 签名,`tauri build` 会跳过签名步骤正常构建,但 CI 那一步会报找不到环境变量的警告。可以先不加这两个 secret,构建如果失败再加。
|
||||
|
||||
2. **首次运行**: GitHub Actions 在第一次 push `.github/workflows/` 后才会出现,之前需要等待。
|
||||
|
||||
3. **MinGW 路径**: `C:\msys64\mingw64\bin` 是 `windows-latest` runner 的固定路径。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
# v4.1 Bug 修复与代码清理 — 设计文档
|
||||
|
||||
**日期**: 2026-05-26
|
||||
**分支**: v4.1
|
||||
**状态**: 已确认
|
||||
|
||||
## 概述
|
||||
|
||||
修复代码审查中发现的 3 个 bug 和 7 个代码质量问题。
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### B1. 非连续删除 undo 位置错误
|
||||
|
||||
**问题**: `deletePaths([0, 3])` 后 undo 将路径恢复到位置 0、1,而非 0、3。
|
||||
|
||||
**修复**: `OpRecord` 新增 `indices: number[]` 字段。DELETE 操作存储已排序的原始 indices。undo 时按 indices 逐个 `splice` 恢复。redo 时按 indices 从后往前删除。
|
||||
|
||||
边角情况:原有连续删除的逻辑不变,indices 为 `[1,2,3]` 时效果与 `index=1, count=3` 一致。
|
||||
|
||||
### B2. 备份时机错误
|
||||
|
||||
**问题**: `backup_registry` 接收前端传来的新值(即将写入的值)做备份,而非当前注册表中的真实值。且 backup 和 save 并发执行无顺序保证。
|
||||
|
||||
**修复**: `backup_registry` 不再接收 paths 参数,改为内部调用 `load_paths()` 读取注册表当前值后写入备份。前端调用简化为 `invoke('backup_registry')`。`split_path`/`join_path` 改为 `pub(crate)` 供 backup 模块复用。
|
||||
|
||||
### B3. 验证异常返回"有效"
|
||||
|
||||
**问题**: `PathTable.tsx` 中 IPC 调用 `validate_path` 失败时 catch 返回 `[p, true]`,不存在的路径被标为绿色。
|
||||
|
||||
**修复**: 验证缓存类型从 `Map<string, boolean>` 改为 `Map<string, 'valid' | 'invalid' | 'unknown'>`。IPC 异常时存 `'unknown'`,UI 渲染为默认色(不标绿也不标红)。
|
||||
|
||||
---
|
||||
|
||||
## 代码清理
|
||||
|
||||
### C1. 删除 AppError 死代码
|
||||
|
||||
删除 `src-tauri/src/error.rs`,移除 `lib.rs` 中的 `mod error`。
|
||||
|
||||
理由:全部 IPC 命令使用 `Result<T, String>`,`AppError` 从未被使用且被 `#[allow(dead_code)]` 压制。
|
||||
|
||||
### C2. importPaths 重命名为 replacePaths
|
||||
|
||||
函数行为是全量替换列表而非追加,名字应诚实。
|
||||
|
||||
### C3. detectExportFormat 修正
|
||||
|
||||
返回类型从 `'json' | 'csv'` 改为 `'json' | 'csv' | 'txt'`。TXT 文件不再被归类为 JSON。
|
||||
|
||||
### C4. _markDirty 收窄可见性
|
||||
|
||||
从 AppState 接口移除 `_markDirty`,改为 store 闭包内的模块级私有函数。CRUD 方法通过闭包直接调用。
|
||||
|
||||
### C5. PATH 长度阈值统一
|
||||
|
||||
`default.json` 中的 `maxSystemLength`/`maxUserLength` 从 2048 改为 32767,与 Rust 端 `MAX_PATH_LEN` 一致。
|
||||
|
||||
---
|
||||
|
||||
## 优化
|
||||
|
||||
### O1. BOM 只在首行检查
|
||||
|
||||
`importFromCsv` 和 `importFromTxt` 中 BOM 检查移到循环外,仅处理第一行。
|
||||
|
||||
### O2. split_path 重复提醒
|
||||
|
||||
在 Rust `registry.rs` 和 TS `validation.ts` 的 `split_path` 函数处各加一行注释,提醒修改时同步两端。
|
||||
@@ -0,0 +1,51 @@
|
||||
# v4.2 CI/CD 流水线 — 设计文档
|
||||
|
||||
**日期**: 2026-05-27
|
||||
**分支**: v4.2
|
||||
**状态**: 已确认
|
||||
|
||||
## 概述
|
||||
|
||||
为 PathEditor 添加 GitHub Actions CI/CD,实现 push 自动检查 + tag 自动构建发布。
|
||||
|
||||
## 触发策略
|
||||
|
||||
| 触发条件 | 做什么 |
|
||||
|----------|--------|
|
||||
| push 任意分支(不含 tag) | 前端类型检查 + lint + 测试,Rust check + clippy + test |
|
||||
| 推送 tag `v*` | Tauri 构建 NSIS 安装包,上传到 GitHub Release |
|
||||
|
||||
## Workflow 1: CI
|
||||
|
||||
**文件**: `.github/workflows/ci.yml`
|
||||
|
||||
两个并行 job:
|
||||
|
||||
**frontend (ubuntu-latest)**:
|
||||
- 用 ubuntu 而非 windows,更快且不依赖系统 API
|
||||
- 步骤:checkout → setup-node → npm ci → tsc --noEmit → npm run lint → npm test
|
||||
|
||||
**rust (windows-latest)**:
|
||||
- 必须用 windows(`winreg` crate 依赖 Windows API)
|
||||
- 安装 GNU 工具链并 override,添加 MinGW bin 到 PATH
|
||||
- 步骤:checkout → rustup toolchain install → override → PATH → cargo check → cargo clippy -- -D warnings → cargo test
|
||||
|
||||
## Workflow 2: Release
|
||||
|
||||
**文件**: `.github/workflows/release.yml`
|
||||
|
||||
单一 job `build-and-release` (windows-latest):
|
||||
- checkout → setup-node → npm ci → rustup + MinGW → npx tauri build → gh release upload
|
||||
|
||||
构建产物:NSIS 安装包(`.exe`),上传到对应 tag 的 GitHub Release。
|
||||
|
||||
## MinGW 处理
|
||||
|
||||
- GitHub Actions `windows-latest` 自带 MSYS2,MinGW 位于 `C:\msys64\mingw64\bin`
|
||||
- `cargo test` 运行时需要 `libmcfgthread-2.dll`,将此路径加入 `PATH` 即可
|
||||
|
||||
## 范围限制
|
||||
|
||||
- 不做跨平台构建(项目仅面向 Windows)
|
||||
- 不做覆盖率门槛
|
||||
- Release 不重复跑 CI(tag 推送说明已通过 push 检查)
|
||||
@@ -0,0 +1,90 @@
|
||||
# v4.3 路径启用/禁用 + E2E 测试 — 设计文档
|
||||
|
||||
**日期**: 2026-05-27
|
||||
**分支**: v4.2(v4.3 后续创建)
|
||||
**状态**: 已确认
|
||||
|
||||
## 概述
|
||||
|
||||
两项独立改进:
|
||||
1. 路径启用/禁用(软开关):`string[]` → `PathEntry[]`,禁用状态存 JSON 文件
|
||||
2. E2E 测试:Playwright + Mock IPC,覆盖 4 条关键流程
|
||||
|
||||
---
|
||||
|
||||
## Part 1: 路径启用/禁用
|
||||
|
||||
### 数据模型
|
||||
|
||||
```typescript
|
||||
// src/core/path-entry.ts (新增)
|
||||
export interface PathEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
全栈从 `string[]` 迁移到 `PathEntry[]`。注册表读写时做转换。
|
||||
|
||||
### 禁用状态持久化
|
||||
|
||||
文件:`%APPDATA%/PathEditor/disabled.json`
|
||||
格式:`{ "system": ["path1"], "user": ["path2"] }`
|
||||
|
||||
加载流程:注册表读取 → `PathEntry[]`(全部 enabled:true)→ 读取 disabled.json → 匹配到的标记 enabled:false
|
||||
|
||||
保存流程:只将 enabled:true 的 path join 后写入注册表
|
||||
|
||||
切换流程:复选框点击 → 更新内存状态 → 立即调用 `save_disabled_state` 写入 JSON
|
||||
|
||||
### 新增 Rust 命令
|
||||
|
||||
- `save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String>`
|
||||
- `load_disabled_state() -> Result<Vec<String>, Vec<String>, String>` 返回 `(system_disabled, user_disabled)`
|
||||
|
||||
### Undo 适配
|
||||
|
||||
- `OpRecord.oldPaths` / `newPaths` 从 `string[]` 改为 `PathEntry[]`
|
||||
- 新增 `OperationType.TOGGLE = 8`:单条路径切换启用/禁用,undo 翻转回去
|
||||
- 8 种已有操作类型的 switch case 适配 `PathEntry`
|
||||
|
||||
### UI
|
||||
|
||||
- 路径列表每行前加复选框,`#` 序号列与复选框合并
|
||||
- 禁用行:灰色文字 + 删除线(`text-decoration: line-through`)
|
||||
- 工具栏不新增按钮(复选框独立操作)
|
||||
|
||||
### 状态层
|
||||
|
||||
- `app-store.ts` 新增 `togglePath(index, target)` 方法
|
||||
- `loadPaths` 加载时合并 disabled 状态
|
||||
- `savePaths` 保存时过滤 disabled 路径
|
||||
|
||||
---
|
||||
|
||||
## Part 2: E2E 测试
|
||||
|
||||
### 技术选型
|
||||
|
||||
`@playwright/test`,独立于 Vitest。Mock Tauri IPC 通过 `page.addInitScript()` 注入。
|
||||
|
||||
### Mock IPC
|
||||
|
||||
页面加载前注入 `window.__TAURI_INTERNALS__.invoke` 的 mock 实现,按命令名返回假数据。
|
||||
|
||||
### 4 条测试场景
|
||||
|
||||
| 场景 | 步骤 |
|
||||
|------|------|
|
||||
| 启动加载 | 访问页面 → 系统 PATH 显示 2 条 → 用户 PATH 显示 1 条 |
|
||||
| CRUD + 撤销 | 添加路径 → 出现在列表 → Ctrl+Z 撤销 → 路径消失 → Ctrl+Y 重做 → 路径恢复 |
|
||||
| 禁用 + 保存 | 点击复选框禁用 → 路径灰显+删除线 → 点保存 → 验证 save IPC 只传了 enabled 路径 |
|
||||
| 搜索 + 清理 | 输入搜索词 → 列表过滤 → 清空搜索 → 点清理 → 无效路径红色 |
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
需要先启动 Vite 开发服务器:`npm run dev`
|
||||
@@ -0,0 +1,24 @@
|
||||
export function createIpcMock() {
|
||||
return `
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (cmd, args) => {
|
||||
switch (cmd) {
|
||||
case 'check_admin': return true;
|
||||
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
|
||||
case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData'];
|
||||
case 'load_disabled_state': return { 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 10000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
locale: 'zh-CN',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createIpcMock } from '../mocks/ipc';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(createIpcMock());
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('添加路径后可撤销和重做', async ({ page }) => {
|
||||
// 点击"新建"按钮
|
||||
await page.click('text=新建');
|
||||
// 填入路径(对话框内自动聚焦的 input)
|
||||
await page.locator('.fixed.inset-0 input[type="text"]').fill('C:\\\\NewPath');
|
||||
// 对话框确认按钮是"确认"
|
||||
await page.click('text=确认');
|
||||
// 路径应出现在列表中
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||||
|
||||
// Ctrl+Z 撤销
|
||||
await page.keyboard.press('Control+z');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('text=C:\\\\NewPath')).not.toBeVisible();
|
||||
|
||||
// Ctrl+Y 重做
|
||||
await page.keyboard.press('Control+y');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('text=C:\\\\NewPath')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
invoke: async (cmd, args) => {
|
||||
switch (cmd) {
|
||||
case 'check_admin': return true;
|
||||
case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
|
||||
case 'load_user_paths': return [];
|
||||
case 'load_disabled_state': return { system: [], user: [] };
|
||||
case 'save_system_paths': return undefined;
|
||||
case 'save_user_paths': return undefined;
|
||||
case 'save_disabled_state': return undefined;
|
||||
case 'backup_registry': return '';
|
||||
case 'broadcast_env_change': return undefined;
|
||||
case 'validate_path': return false;
|
||||
case 'expand_env_vars': return '';
|
||||
case 'read_text_file': return '';
|
||||
case 'get_appdata_dir': return '';
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('搜索过滤后清理无效路径', async ({ page }) => {
|
||||
// 初始 3 条路径
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(3);
|
||||
|
||||
// 搜索 "Windows"
|
||||
const searchInput = page.locator('input[placeholder]');
|
||||
await searchInput.fill('Windows');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||||
|
||||
// 清除搜索
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(3);
|
||||
|
||||
// 点击"一键清理"按钮
|
||||
await page.click('text=一键清理');
|
||||
await page.waitForTimeout(300);
|
||||
// is_valid_path_format 只校验格式,不检查存在性
|
||||
// "invalid_path" 格式无效被移除,C:\Windows 和 C:\Temp 格式有效保留
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createIpcMock } from '../mocks/ipc';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(createIpcMock());
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('启动后加载系统 PATH 和用户 PATH', async ({ page }) => {
|
||||
// 系统 tab 默认激活,显示 2 条路径
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||||
|
||||
// 切换到用户 tab
|
||||
await page.click('text=用户变量');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createIpcMock } from '../mocks/ipc';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(createIpcMock());
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('禁用路径后灰显并保存', async ({ page }) => {
|
||||
// 点击第一个路径的 checkbox 将其禁用
|
||||
const checkbox = page.locator('table tbody tr').first().locator('input[type="checkbox"]');
|
||||
await checkbox.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 路径文本应有删除线样式(第 3 列是路径列,nth(2) 即 0-indexed 第 3 个 td)
|
||||
const row = page.locator('table tbody tr').first();
|
||||
await expect(row.locator('td').nth(2)).toHaveCSS('text-decoration-line', 'line-through');
|
||||
|
||||
// 点击"确定"保存
|
||||
await page.click('text=确定');
|
||||
await page.waitForTimeout(500);
|
||||
// 状态栏应显示"保存成功"
|
||||
await expect(page.locator('text=保存成功')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src-tauri'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+68
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "v4.0",
|
||||
"version": "0.0.0",
|
||||
"name": "patheditor",
|
||||
"version": "4.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "v4.0",
|
||||
"version": "0.0.0",
|
||||
"name": "patheditor",
|
||||
"version": "4.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -582,6 +583,22 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||
|
||||
+3
-1
@@ -9,7 +9,8 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test --config e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
use chrono::Local;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use winreg::enums::*;
|
||||
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
|
||||
|
||||
fn backup_base_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
@@ -17,27 +18,38 @@ pub fn get_appdata_dir() -> String {
|
||||
}
|
||||
|
||||
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
||||
/// 返回备份文件的路径
|
||||
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
|
||||
#[tauri::command]
|
||||
pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_paths: Vec<String>) -> Result<String, String> {
|
||||
// 确定备份目录
|
||||
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
|
||||
let backup_dir = match custom_dir {
|
||||
Some(ref dir) if !dir.is_empty() => PathBuf::from(dir),
|
||||
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
|
||||
_ => backup_base_dir(),
|
||||
};
|
||||
|
||||
// 创建目录
|
||||
fs::create_dir_all(&backup_dir)
|
||||
std::fs::create_dir_all(&backup_dir)
|
||||
.map_err(|e| format!("无法创建备份目录: {}", e))?;
|
||||
|
||||
// 生成带时间戳的文件名
|
||||
// 读取当前注册表中的值(保存前的旧值)
|
||||
let sys_paths = registry::load_paths(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
SYS_REG_PATH,
|
||||
"系统",
|
||||
)?;
|
||||
let user_paths = registry::load_paths(
|
||||
HKEY_CURRENT_USER,
|
||||
USER_REG_PATH,
|
||||
"用户",
|
||||
)?;
|
||||
|
||||
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f");
|
||||
let filename = format!("path_backup_{}.txt", timestamp);
|
||||
let filepath = backup_dir.join(&filename);
|
||||
|
||||
// 写入备份内容
|
||||
let mut content = String::new();
|
||||
content.push_str(&format!("PathEditor Backup - {}\n", Local::now().format("%Y-%m-%d %H:%M:%S")));
|
||||
content.push_str(&format!(
|
||||
"PathEditor Backup - {}\n",
|
||||
Local::now().format("%Y-%m-%d %H:%M:%S")
|
||||
));
|
||||
content.push_str("\n[System PATH]\n");
|
||||
for path in &sys_paths {
|
||||
content.push_str(&format!("{}\n", path));
|
||||
@@ -47,7 +59,7 @@ pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_
|
||||
content.push_str(&format!("{}\n", path));
|
||||
}
|
||||
|
||||
fs::write(&filepath, &content)
|
||||
std::fs::write(&filepath, &content)
|
||||
.map_err(|e| format!("无法写入备份文件: {}", e))?;
|
||||
|
||||
let result = filepath.to_string_lossy().to_string();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
|
||||
#[tauri::command]
|
||||
pub fn read_text_file(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod registry;
|
||||
pub mod system;
|
||||
pub mod backup;
|
||||
pub mod fs;
|
||||
pub mod disabled;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
||||
const USER_REG_PATH: &str = "Environment";
|
||||
const PATH_VALUE: &str = "Path";
|
||||
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
|
||||
pub(crate) const USER_REG_PATH: &str = "Environment";
|
||||
pub(crate) const PATH_VALUE: &str = "Path";
|
||||
|
||||
fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let key = RegKey::predef(root);
|
||||
let env_key = key
|
||||
.open_subkey_with_flags(sub_path, KEY_READ)
|
||||
@@ -18,7 +18,7 @@ fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<Str
|
||||
Ok(split_path(&value))
|
||||
}
|
||||
|
||||
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
|
||||
pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
|
||||
let value = join_path(paths);
|
||||
|
||||
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
|
||||
@@ -63,14 +63,16 @@ pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
||||
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
|
||||
}
|
||||
|
||||
fn split_path(raw: &str) -> Vec<String> {
|
||||
/// 将分号分隔的 PATH 字符串拆分为数组。
|
||||
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
|
||||
pub(crate) fn split_path(raw: &str) -> Vec<String> {
|
||||
raw.split(';')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn join_path(paths: &[String]) -> String {
|
||||
pub(crate) fn join_path(paths: &[String]) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| p.trim())
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// 传给前端的统一错误类型(保留供未来迁移使用,届时所有命令改为返回 Result<T, AppError>)
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppError {
|
||||
fn from(s: &str) -> Self {
|
||||
AppError {
|
||||
message: s.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for AppError {
|
||||
fn from(s: String) -> Self {
|
||||
AppError { message: s }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AppError {
|
||||
message: format!("IO 错误: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
mod commands;
|
||||
mod error;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -26,6 +25,9 @@ pub fn run() {
|
||||
commands::system::broadcast_env_change,
|
||||
commands::backup::backup_registry,
|
||||
commands::backup::get_appdata_dir,
|
||||
commands::fs::read_text_file,
|
||||
commands::disabled::save_disabled_state,
|
||||
commands::disabled::load_disabled_state,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "nsis",
|
||||
"resources": ["WebView2Loader.dll"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -14,6 +14,8 @@ export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,11 @@ import { HelpDialog } from '@/components/dialogs/HelpDialog';
|
||||
import { ImportDialog } from '@/components/dialogs/ImportDialog';
|
||||
import { useAppActions, type DialogState } from '@/hooks/use-app-actions';
|
||||
|
||||
/** Tauri's File object includes the native filesystem path */
|
||||
interface TauriFile extends File {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const activeTab = useAppStore((s) => s.activeTab);
|
||||
@@ -84,7 +89,7 @@ export function AppShell() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
className="flex-1 overflow-auto"
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -92,8 +97,8 @@ export function AppShell() {
|
||||
for (let i = 0; i < e.dataTransfer.items.length; i++) {
|
||||
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
||||
if (entry?.isDirectory) {
|
||||
const path = (e.dataTransfer.files[i] as any).path;
|
||||
if (path) useAppStore.getState().addPath(path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
const file = e.dataTransfer.files[i] as TauriFile;
|
||||
if (file.path) useAppStore.getState().addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PathEntry } from '@/core/path-entry';
|
||||
|
||||
export function MergePreview() {
|
||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||
@@ -9,13 +10,27 @@ export function MergePreview() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allPaths = useMemo(() => {
|
||||
const result: { path: string; source: string; index: number }[] = [];
|
||||
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
|
||||
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
|
||||
const seen = new Set<string>();
|
||||
const merged: (PathEntry & { source: string; displayIndex: number })[] = [];
|
||||
|
||||
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();
|
||||
return result.filter((r) => r.path.toLowerCase().includes(q));
|
||||
return merged.filter((r) => r.path.toLowerCase().includes(q));
|
||||
}, [sysPaths, userPaths, searchQuery, t]);
|
||||
|
||||
return (
|
||||
@@ -32,20 +47,31 @@ export function MergePreview() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allPaths.map(({ path, source, index }, rowIdx) => (
|
||||
<tr
|
||||
key={`${source}-${index}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||
color: 'var(--app-fg)',
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
|
||||
<td className="px-2 py-0.5 text-sm">{path}</td>
|
||||
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
|
||||
</tr>
|
||||
))}
|
||||
{allPaths.map(({ path, enabled, source, displayIndex }, rowIdx) => {
|
||||
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
|
||||
const textDecoration = enabled ? 'none' : 'line-through';
|
||||
const opacity = enabled ? 1 : 0.6;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${source}-${displayIndex}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||
color: 'var(--app-fg)',
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
|
||||
<td
|
||||
className="px-2 py-0.5 text-sm"
|
||||
style={{ color: textColor, textDecoration, opacity }}
|
||||
>
|
||||
{path}
|
||||
</td>
|
||||
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { TargetType } from '@/core/undo-redo';
|
||||
|
||||
interface PathTableProps {
|
||||
tabId: 'system' | 'user';
|
||||
@@ -9,8 +10,12 @@ interface PathTableProps {
|
||||
interface PathRow {
|
||||
path: string;
|
||||
index: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type ValidationState = 'valid' | 'invalid' | 'unknown';
|
||||
const DEFAULT_VALIDATION_STATE: ValidationState = 'valid';
|
||||
|
||||
export function PathTable({ tabId }: PathTableProps) {
|
||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||
const userPaths = useAppStore((s) => s.userPaths);
|
||||
@@ -22,19 +27,22 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
const paths = tabId === 'system' ? sysPaths : userPaths;
|
||||
const isActive = activeTab === tabId;
|
||||
|
||||
// 本次会话中已验证过的路径缓存(key=path, value=isValid)
|
||||
const [validationCache, setValidationCache] = useState<Map<string, boolean>>(new Map());
|
||||
// 本次会话中已验证过的路径缓存(key=path, value=ValidationState)
|
||||
const [validationCache, setValidationCache] = useState<Map<string, ValidationState>>(new Map());
|
||||
// 环境变量展开结果缓存(key=path, value=expanded)
|
||||
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(new Map());
|
||||
|
||||
const validatedRef = useRef<Set<string>>(new Set());
|
||||
const expandedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 过滤搜索
|
||||
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 result: PathRow[] = [];
|
||||
for (let i = 0; i < paths.length; 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;
|
||||
}, [paths, searchQuery]);
|
||||
@@ -42,74 +50,65 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
// 异步验证未缓存的路径
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const allPaths = paths;
|
||||
|
||||
// 找出未缓存的路径
|
||||
const toValidate = allPaths.filter((p) => !validationCache.has(p));
|
||||
const toValidate = paths.filter((p) => !validatedRef.current.has(p.path));
|
||||
if (toValidate.length === 0) return;
|
||||
|
||||
// 批量验证(限制并发 20)
|
||||
const batch = toValidate.slice(0, 20);
|
||||
Promise.all(
|
||||
batch.map(async (p): Promise<[string, boolean]> => {
|
||||
batch.map(async (p): Promise<[string, ValidationState]> => {
|
||||
try {
|
||||
if (p.includes('%')) return [p, true];
|
||||
const valid: boolean = await invoke('validate_path', { path: p });
|
||||
return [p, valid];
|
||||
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, true];
|
||||
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);
|
||||
}
|
||||
for (const [p, v] of results) next.set(p, v);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [paths, validationCache]);
|
||||
return () => { cancelled = true; };
|
||||
}, [paths]);
|
||||
|
||||
// 异步展开环境变量(用于 tooltip)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toExpand = paths.filter(
|
||||
(p) => p.includes('%') && !expandedCache.has(p),
|
||||
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
|
||||
);
|
||||
if (toExpand.length === 0) return;
|
||||
|
||||
const batch = toExpand.slice(0, 20);
|
||||
Promise.all(
|
||||
toExpand.map(async (p): Promise<[string, string]> => {
|
||||
batch.map(async (p): Promise<[string, string]> => {
|
||||
try {
|
||||
const expanded: string = await invoke('expand_env_vars', { path: p });
|
||||
return [p, expanded !== p ? expanded : ''];
|
||||
const expanded: string = await invoke('expand_env_vars', { path: p.path });
|
||||
return [p.path, expanded !== p.path ? expanded : ''];
|
||||
} catch {
|
||||
return [p, ''];
|
||||
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);
|
||||
}
|
||||
for (const [p, v] of results) next.set(p, v);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [paths, expandedCache]);
|
||||
return () => { cancelled = true; };
|
||||
}, [paths]);
|
||||
|
||||
// 所有路径都默认有效(异步验证结果回来后再精确染色)
|
||||
// 所有路径默认有效(异步验证结果回来后再精确染色)
|
||||
const validations = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return filtered.map(({ path }) => {
|
||||
@@ -117,7 +116,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
const isDuplicate = seen.has(lower);
|
||||
seen.add(lower);
|
||||
return {
|
||||
isValid: validationCache.get(path) ?? true,
|
||||
state: validationCache.get(path) ?? DEFAULT_VALIDATION_STATE,
|
||||
isDuplicate,
|
||||
isEnvVar: path.includes('%'),
|
||||
};
|
||||
@@ -144,7 +143,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
if (!isActive) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('path-dblclick', {
|
||||
detail: { index: realIndex, path: paths[realIndex] },
|
||||
detail: { index: realIndex, path: paths[realIndex].path },
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -160,16 +159,26 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<th className="w-8 px-2 py-1">#</th>
|
||||
<th className="w-6 px-1 py-1"></th>
|
||||
<th className="px-2 py-1">路径</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(({ path, index }, rowIdx) => {
|
||||
{filtered.map(({ path, index, enabled }, rowIdx) => {
|
||||
const v = validations[rowIdx];
|
||||
const isSelected = selectedIndices.includes(index);
|
||||
let textColor = 'var(--app-fg)';
|
||||
if (!v.isValid) textColor = '#dc3545';
|
||||
if (v.state === 'invalid') textColor = '#dc3545';
|
||||
else if (v.isDuplicate) textColor = '#fd7e14';
|
||||
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
|
||||
|
||||
let textDecoration = 'none';
|
||||
let opacity = 1;
|
||||
if (!enabled) {
|
||||
textColor = '#6b7280';
|
||||
textDecoration = 'line-through';
|
||||
opacity = 0.6;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -188,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)' }}>
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="w-6 px-1 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
|
||||
useAppStore.getState().togglePath(index, target);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
||||
style={{ color: textColor }}
|
||||
style={{ color: textColor, textDecoration, opacity }}
|
||||
title={expandedCache.get(path) || undefined}
|
||||
>
|
||||
{path}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"dir": ""
|
||||
},
|
||||
"path": {
|
||||
"maxSystemLength": 2048,
|
||||
"maxUserLength": 2048,
|
||||
"maxCombinedLength": 8191
|
||||
"maxSystemLength": 32767,
|
||||
"maxUserLength": 32767,
|
||||
"maxCombinedLength": 32767
|
||||
}
|
||||
}
|
||||
|
||||
+35
-30
@@ -2,16 +2,20 @@
|
||||
* 导入导出模块 — 对应 C 版 import_export.c
|
||||
* 支持 JSON、CSV、TXT 三种格式
|
||||
*/
|
||||
export type ExportFormat = 'json' | 'csv';
|
||||
import type { PathEntry } from './path-entry';
|
||||
|
||||
export type ExportFormat = 'json' | 'csv' | 'txt';
|
||||
|
||||
export interface ExportData {
|
||||
system: string[];
|
||||
user: string[];
|
||||
system: PathEntry[];
|
||||
user: PathEntry[];
|
||||
}
|
||||
|
||||
/** 根据文件扩展名检测格式 */
|
||||
export function detectExportFormat(filepath: string): ExportFormat {
|
||||
if (filepath.toLowerCase().endsWith('.csv')) return 'csv';
|
||||
const lower = filepath.toLowerCase();
|
||||
if (lower.endsWith('.csv')) return 'csv';
|
||||
if (lower.endsWith('.txt')) return 'txt';
|
||||
return 'json';
|
||||
}
|
||||
|
||||
@@ -22,8 +26,8 @@ export function exportToJson(data: ExportData): string {
|
||||
version: '1.0',
|
||||
type: 'PathEditor',
|
||||
exported: new Date().toISOString(),
|
||||
system: data.system,
|
||||
user: data.user,
|
||||
system: data.system.map(e => e.path),
|
||||
user: data.user.map(e => e.path),
|
||||
};
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
@@ -35,11 +39,11 @@ export function exportToCsv(data: ExportData): string {
|
||||
// UTF-8 BOM
|
||||
lines.push('type,path');
|
||||
|
||||
for (const path of data.system) {
|
||||
lines.push(`system,${escapeCsvField(path)}`);
|
||||
for (const entry of data.system) {
|
||||
lines.push(`system,${escapeCsvField(entry.path)}`);
|
||||
}
|
||||
for (const path of data.user) {
|
||||
lines.push(`user,${escapeCsvField(path)}`);
|
||||
for (const entry of data.user) {
|
||||
lines.push(`user,${escapeCsvField(entry.path)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
@@ -55,8 +59,8 @@ function escapeCsvField(field: string): string {
|
||||
// ── CSV 导入 ──
|
||||
|
||||
export interface ImportResult {
|
||||
system: string[];
|
||||
user: string[];
|
||||
system: PathEntry[];
|
||||
user: PathEntry[];
|
||||
}
|
||||
|
||||
export function importFromCsv(content: string): ImportResult {
|
||||
@@ -65,10 +69,10 @@ export function importFromCsv(content: string): ImportResult {
|
||||
|
||||
let hasHeader = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
// 跳过 BOM
|
||||
let line = rawLine;
|
||||
if (line.startsWith('')) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// 跳过 BOM(仅首行)
|
||||
let line = lines[i];
|
||||
if (i === 0 && line.startsWith('')) {
|
||||
line = line.slice(1);
|
||||
}
|
||||
|
||||
@@ -89,9 +93,9 @@ export function importFromCsv(content: string): ImportResult {
|
||||
if (path.length === 0) continue;
|
||||
|
||||
if (type === 'system') {
|
||||
result.system.push(path);
|
||||
result.system.push({ path, enabled: true });
|
||||
} else if (type === 'user') {
|
||||
result.user.push(path);
|
||||
result.user.push({ path, enabled: true });
|
||||
}
|
||||
// 未知类型忽略
|
||||
}
|
||||
@@ -155,14 +159,14 @@ export function importFromJson(content: string): ImportResult {
|
||||
if (typeof obj !== 'object' || obj === null) return result;
|
||||
|
||||
if (Array.isArray(obj.system)) {
|
||||
result.system = obj.system.filter(
|
||||
(p: unknown) => typeof p === 'string' && p.trim().length > 0,
|
||||
);
|
||||
result.system = obj.system
|
||||
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||||
}
|
||||
if (Array.isArray(obj.user)) {
|
||||
result.user = obj.user.filter(
|
||||
(p: unknown) => typeof p === 'string' && p.trim().length > 0,
|
||||
);
|
||||
result.user = obj.user
|
||||
.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||
.map((p: string) => ({ path: p.trim(), enabled: true }));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -170,18 +174,19 @@ export function importFromJson(content: string): ImportResult {
|
||||
|
||||
// ── TXT 导入 ──
|
||||
|
||||
export function importFromTxt(content: string): string[] {
|
||||
const paths: string[] = [];
|
||||
export function importFromTxt(content: string): PathEntry[] {
|
||||
const paths: PathEntry[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let line of lines) {
|
||||
// 跳过 BOM
|
||||
if (line.startsWith('')) line = line.slice(1);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// 跳过 BOM(仅首行)
|
||||
let line = lines[i];
|
||||
if (i === 0 && line.startsWith('')) line = line.slice(1);
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
|
||||
paths.push(trimmed);
|
||||
paths.push({ path: trimmed, enabled: true });
|
||||
}
|
||||
|
||||
return paths;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** PATH 路径条目 — 包含路径值和启用状态 */
|
||||
export interface PathEntry {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 路径管理器 — 不可变的 string[] 操作
|
||||
* 路径管理器 — 不可变的 PathEntry[] 操作
|
||||
*/
|
||||
|
||||
import type { PathEntry } from './path-entry';
|
||||
|
||||
export interface PathValidation {
|
||||
isValid: boolean;
|
||||
isDuplicate: boolean;
|
||||
@@ -9,17 +11,17 @@ export interface PathValidation {
|
||||
}
|
||||
|
||||
export function analyzePaths(
|
||||
paths: readonly string[],
|
||||
paths: readonly PathEntry[],
|
||||
validateFn: (path: string) => boolean,
|
||||
): PathValidation[] {
|
||||
const result: PathValidation[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const path of paths) {
|
||||
const lower = path.toLowerCase();
|
||||
for (const entry of paths) {
|
||||
const lower = entry.path.toLowerCase();
|
||||
const isDuplicate = seen.has(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;
|
||||
@@ -27,12 +29,12 @@ export function analyzePaths(
|
||||
|
||||
/** 从数组中移除无效和重复路径,返回 [新数组, 被移除的路径] */
|
||||
export function pathClean(
|
||||
paths: readonly string[],
|
||||
paths: readonly PathEntry[],
|
||||
validateFn: (path: string) => boolean,
|
||||
): [string[], string[]] {
|
||||
): [PathEntry[], PathEntry[]] {
|
||||
const analysis = analyzePaths(paths, validateFn);
|
||||
const kept: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const kept: PathEntry[] = [];
|
||||
const removed: PathEntry[] = [];
|
||||
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const a = analysis[i];
|
||||
|
||||
+32
-10
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 撤销/重做管理器 — 纯逻辑,操作不可变 string[]
|
||||
* 撤销/重做管理器 — 纯逻辑,操作不可变 PathEntry[]
|
||||
*/
|
||||
|
||||
import type { PathEntry } from './path-entry';
|
||||
|
||||
export const OperationType = {
|
||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7,
|
||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8,
|
||||
} as const;
|
||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||
|
||||
@@ -15,8 +17,10 @@ export interface OpRecord {
|
||||
target: TargetType;
|
||||
index: number;
|
||||
count: number;
|
||||
oldPaths: string[];
|
||||
newPaths: string[];
|
||||
oldPaths: PathEntry[];
|
||||
newPaths: PathEntry[];
|
||||
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
|
||||
indices?: number[];
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE = 50;
|
||||
@@ -39,7 +43,7 @@ export class UndoRedoManager {
|
||||
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;
|
||||
|
||||
const rec = this.records[this.current];
|
||||
@@ -54,8 +58,14 @@ export class UndoRedoManager {
|
||||
target.splice(target.length - rec.count, rec.count);
|
||||
break;
|
||||
case OperationType.DELETE:
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.splice(rec.index + i, 0, rec.oldPaths[i]);
|
||||
if (rec.indices) {
|
||||
for (let i = 0; i < rec.indices.length; i++) {
|
||||
target.splice(rec.indices[i], 0, rec.oldPaths[i]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.splice(rec.index + i, 0, rec.oldPaths[i]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case OperationType.EDIT:
|
||||
@@ -75,12 +85,15 @@ export class UndoRedoManager {
|
||||
case OperationType.CLEAR:
|
||||
target.push(...rec.oldPaths);
|
||||
break;
|
||||
case OperationType.TOGGLE:
|
||||
target[rec.index] = rec.oldPaths[0];
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.current++;
|
||||
@@ -95,8 +108,14 @@ export class UndoRedoManager {
|
||||
target.push(...rec.newPaths);
|
||||
break;
|
||||
case OperationType.DELETE:
|
||||
for (let i = rec.count - 1; i >= 0; i--) {
|
||||
target.splice(rec.index + i, 1);
|
||||
if (rec.indices) {
|
||||
for (let i = rec.indices.length - 1; i >= 0; i--) {
|
||||
target.splice(rec.indices[i], 1);
|
||||
}
|
||||
} else {
|
||||
for (let i = rec.count - 1; i >= 0; i--) {
|
||||
target.splice(rec.index + i, 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case OperationType.EDIT:
|
||||
@@ -116,6 +135,9 @@ export class UndoRedoManager {
|
||||
case OperationType.CLEAR:
|
||||
target.length = 0;
|
||||
break;
|
||||
case OperationType.TOGGLE:
|
||||
target[rec.index] = rec.newPaths[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return [sys, user];
|
||||
|
||||
@@ -26,7 +26,8 @@ export function join_path(paths: string[]): string {
|
||||
return paths.join(';');
|
||||
}
|
||||
|
||||
/** 分割 PATH 字符串 */
|
||||
/** 分割 PATH 字符串。
|
||||
* 注意:Rust 端 src-tauri/src/commands/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
|
||||
export function split_path(raw: string): string[] {
|
||||
return raw
|
||||
.split(';')
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { TargetType } from '@/core/undo-redo';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
||||
import type { PathEntry } from '@/core/path-entry';
|
||||
import { is_valid_path_format } from '@/core/validation';
|
||||
import { useKeyboard } from './use-keyboard';
|
||||
import i18n from '@/i18n';
|
||||
@@ -12,7 +14,7 @@ export interface DialogState {
|
||||
editDialog: { open: boolean; index: number; value: string; target: TargetType };
|
||||
newDialog: boolean;
|
||||
helpOpen: boolean;
|
||||
importDialog: { open: boolean; system: string[]; user: string[] };
|
||||
importDialog: { open: boolean; system: PathEntry[]; user: PathEntry[] };
|
||||
setEditDialog: (v: DialogState['editDialog']) => void;
|
||||
setNewDialog: (v: boolean) => void;
|
||||
setHelpOpen: (v: boolean) => void;
|
||||
@@ -37,8 +39,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
const list = target === TargetType.SYSTEM
|
||||
? useAppStore.getState().sysPaths
|
||||
: useAppStore.getState().userPaths;
|
||||
const value = list[idx];
|
||||
if (value) setEditDialog({ open: true, index: idx, value, target });
|
||||
const entry = list[idx];
|
||||
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
|
||||
}, [activeTab, setEditDialog]);
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
@@ -80,25 +82,21 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
|
||||
// ── 导入导出 ──
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json,.csv,.txt';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) { input.remove(); return; }
|
||||
const content = await file.text();
|
||||
const result = importFromContent(content, file.name);
|
||||
input.remove();
|
||||
if (result.system.length > 0 && result.user.length > 0) {
|
||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||
} else if (result.system.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.SYSTEM, result.system);
|
||||
} else if (result.user.length > 0) {
|
||||
useAppStore.getState().importPaths(TargetType.USER, result.user);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
const handleImport = useCallback(async () => {
|
||||
const selected = await open({
|
||||
filters: [{ name: '受支持格式', extensions: ['json', 'csv', 'txt'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (!selected || typeof selected !== 'string') return;
|
||||
const content = await invoke<string>('read_text_file', { path: selected });
|
||||
const result = importFromContent(content, selected);
|
||||
if (result.system.length > 0 && result.user.length > 0) {
|
||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||
} else if (result.system.length > 0) {
|
||||
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system.map(e => e.path));
|
||||
} else if (result.user.length > 0) {
|
||||
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
|
||||
}
|
||||
}, [setImportDialog]);
|
||||
|
||||
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
|
||||
@@ -162,8 +160,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
||||
const { system, user } = dialogs.importDialog;
|
||||
const flat = flattenImportResult({ system, user }, target);
|
||||
if (flat.system.length > 0) useAppStore.getState().importPaths(TargetType.SYSTEM, flat.system);
|
||||
if (flat.user.length > 0) useAppStore.getState().importPaths(TargetType.USER, flat.user);
|
||||
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
|
||||
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
|
||||
setImportDialog({ open: false, system: [], user: [] });
|
||||
}, [dialogs.importDialog, setImportDialog]);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface KeyboardActions {
|
||||
export function useKeyboard(actions: KeyboardActions) {
|
||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||
const actionsRef = useRef(actions);
|
||||
// eslint-disable-next-line react-hooks/refs -- React 官方推荐的 ref 同步模式,避免每次渲染重复注册事件监听器
|
||||
actionsRef.current = actions;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+103
-48
@@ -3,16 +3,17 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '@/i18n';
|
||||
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
||||
import { pathClean } from '@/core/path-manager';
|
||||
import type { PathEntry } from '@/core/path-entry';
|
||||
import appConfig from '@/config/default.json';
|
||||
|
||||
export type TabId = 'system' | 'user' | 'merged';
|
||||
|
||||
interface AppState {
|
||||
sysPaths: string[];
|
||||
userPaths: string[];
|
||||
sysPaths: PathEntry[];
|
||||
userPaths: PathEntry[];
|
||||
undoRedo: UndoRedoManager;
|
||||
_savedSys: string[]; // 上次保存时的快照,用于 isModified 判断
|
||||
_savedUser: string[];
|
||||
_savedSys: PathEntry[]; // 上次保存时的快照,用于 isModified 判断
|
||||
_savedUser: PathEntry[];
|
||||
|
||||
activeTab: TabId;
|
||||
searchQuery: string;
|
||||
@@ -34,9 +35,11 @@ interface AppState {
|
||||
moveUp: (index: number, target: TargetType) => void;
|
||||
moveDown: (index: number, target: TargetType) => void;
|
||||
cleanPaths: (target: TargetType, validateFn: (p: string) => boolean) => string[];
|
||||
importPaths: (target: TargetType, importPaths: string[]) => void;
|
||||
replacePaths: (target: TargetType, newPaths: string[]) => void;
|
||||
clearPaths: (target: TargetType) => void;
|
||||
|
||||
togglePath: (index: number, target: TargetType) => void;
|
||||
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
|
||||
@@ -44,15 +47,20 @@ interface AppState {
|
||||
savePaths: () => Promise<void>;
|
||||
initialize: () => Promise<void>;
|
||||
|
||||
_markDirty: () => void;
|
||||
}
|
||||
|
||||
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
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);
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
sysPaths: [],
|
||||
export const useAppStore = create<AppState>((set, get) => {
|
||||
const markDirty = () => {
|
||||
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
|
||||
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
|
||||
};
|
||||
|
||||
return {
|
||||
sysPaths: [],
|
||||
userPaths: [],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
_savedSys: [],
|
||||
@@ -75,51 +83,54 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
addPath: (path, target) => {
|
||||
const state = get();
|
||||
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({
|
||||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||||
oldPaths: [], newPaths: [path],
|
||||
oldPaths: [], newPaths: [entry],
|
||||
});
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
},
|
||||
|
||||
editPath: (index, newPath, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldPath = list[index];
|
||||
if (oldPath === undefined) return;
|
||||
const oldEntry = list[index];
|
||||
if (!oldEntry) return;
|
||||
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||||
state.undoRedo.push({
|
||||
type: OperationType.EDIT, target, index, count: 1,
|
||||
oldPaths: [oldPath], newPaths: [newPath],
|
||||
oldPaths: [oldEntry], newPaths: [newEntry],
|
||||
});
|
||||
const newList = [...list];
|
||||
newList[index] = newPath;
|
||||
newList[index] = newEntry;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
},
|
||||
|
||||
deletePaths: (indices, target) => {
|
||||
if (indices.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const sorted = [...indices].sort((a, b) => b - a);
|
||||
const oldPaths = sorted.map((i) => list[i]);
|
||||
const sortedDesc = [...indices].sort((a, b) => b - a);
|
||||
const sortedAsc = [...indices].sort((a, b) => a - b);
|
||||
const oldPaths = sortedAsc.map((i) => list[i]);
|
||||
|
||||
// 单条撤销记录覆盖全部删除
|
||||
state.undoRedo.push({
|
||||
type: OperationType.DELETE, target,
|
||||
index: sorted[sorted.length - 1], count: sorted.length,
|
||||
index: sortedAsc[0], count: sortedAsc.length,
|
||||
oldPaths, newPaths: [],
|
||||
indices: sortedAsc,
|
||||
});
|
||||
|
||||
const toRemove = new Set(sorted);
|
||||
const toRemove = new Set(sortedDesc);
|
||||
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
|
||||
else set({ userPaths: newList, selectedIndices: [] });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveUp: (index, target) => {
|
||||
@@ -133,7 +144,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index - 1] });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveDown: (index, target) => {
|
||||
@@ -147,7 +158,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index + 1] });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
},
|
||||
|
||||
cleanPaths: (target, validateFn) => {
|
||||
@@ -162,25 +173,26 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
});
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
||||
else set({ userPaths: kept, selectedIndices: [] });
|
||||
get()._markDirty();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
return removed;
|
||||
return removed.map(e => e.path);
|
||||
},
|
||||
|
||||
importPaths: (target, importPaths) => {
|
||||
if (importPaths.length === 0) return;
|
||||
replacePaths: (target, newPaths) => {
|
||||
if (newPaths.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const entries: PathEntry[] = newPaths.map(p => ({ path: p, enabled: true }));
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT, target, index: 0, count: importPaths.length,
|
||||
oldPaths: [...list], newPaths: [...importPaths],
|
||||
type: OperationType.IMPORT, target, index: 0, count: entries.length,
|
||||
oldPaths: [...list], newPaths: [...entries],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...importPaths], selectedIndices: [] });
|
||||
else set({ userPaths: [...importPaths], selectedIndices: [] });
|
||||
get()._markDirty();
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
||||
else set({ userPaths: [...entries], selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
clearPaths: (target) => {
|
||||
@@ -195,7 +207,33 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
||||
else set({ userPaths: [] });
|
||||
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: () => {
|
||||
@@ -204,6 +242,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
}
|
||||
@@ -215,16 +254,12 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_markDirty: () => {
|
||||
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
|
||||
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
|
||||
},
|
||||
|
||||
loadPaths: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
@@ -232,9 +267,27 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
invoke<string[]>('load_system_paths'),
|
||||
invoke<string[]>('load_user_paths'),
|
||||
]);
|
||||
|
||||
// 加载禁用状态(文件不存在时返回空)
|
||||
let sysDisabled: string[] = [];
|
||||
let usrDisabled: string[] = [];
|
||||
try {
|
||||
const result = await invoke<[string[], string[]]>('load_disabled_state');
|
||||
sysDisabled = result[0];
|
||||
usrDisabled = result[1];
|
||||
} catch {
|
||||
// 文件不存在或损坏,忽略
|
||||
}
|
||||
|
||||
const sysSet = new Set(sysDisabled);
|
||||
const usrSet = new Set(usrDisabled);
|
||||
|
||||
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysSet.has(p) }));
|
||||
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrSet.has(p) }));
|
||||
|
||||
set({
|
||||
sysPaths: sysArr, userPaths: userArr,
|
||||
_savedSys: [...sysArr], _savedUser: [...userArr],
|
||||
sysPaths: sysEntries, userPaths: usrEntries,
|
||||
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
isLoading: false, isModified: false,
|
||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||||
@@ -249,7 +302,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
if (state.isSaving) return;
|
||||
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 userJoined = userPaths.join(';');
|
||||
|
||||
@@ -258,8 +313,8 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
if (!window.confirm('PATH 长度超过建议值,是否继续保存?')) { set({ isSaving: false }); return; }
|
||||
}
|
||||
|
||||
// 备份(失败时通知用户)
|
||||
invoke('backup_registry', { customDir: null, sysPaths, userPaths })
|
||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||
await invoke('backup_registry', { customDir: null })
|
||||
.catch(() => set({ statusMessage: i18n.t('status.warning_backup') }));
|
||||
|
||||
const [sysResult, userResult] = await Promise.allSettled([
|
||||
@@ -272,7 +327,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
|
||||
if (sysOk && userOk) {
|
||||
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 });
|
||||
} else {
|
||||
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
|
||||
@@ -292,4 +347,4 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
await get().loadPaths();
|
||||
},
|
||||
}));
|
||||
};});
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock @tauri-apps/api/core
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@/i18n', () => ({
|
||||
default: { t: vi.fn((key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'status.loaded') return `已加载 ${opts?.sysCount} 条系统 PATH,${opts?.userCount} 条用户 PATH`;
|
||||
if (key === 'status.error') return '加载失败';
|
||||
if (key === 'status.saving') return '正在保存...';
|
||||
if (key === 'status.saved') return '保存成功';
|
||||
if (key === 'status.warning_backup') return '备份失败,但保存继续';
|
||||
if (key === 'status.readonly') return '只读模式';
|
||||
if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`;
|
||||
return key;
|
||||
}) },
|
||||
}));
|
||||
|
||||
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 { UndoRedoManager, TargetType } from '@/core/undo-redo';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
|
||||
function resetStore() {
|
||||
useAppStore.setState({
|
||||
sysPaths: [],
|
||||
userPaths: [],
|
||||
undoRedo: new UndoRedoManager(50),
|
||||
_savedSys: [],
|
||||
_savedUser: [],
|
||||
isModified: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
selectedIndices: [],
|
||||
searchQuery: '',
|
||||
statusMessage: '',
|
||||
});
|
||||
}
|
||||
|
||||
describe('app-store CRUD', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('addPath 追加到 sysPaths', () => {
|
||||
useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM);
|
||||
const s = useAppStore.getState();
|
||||
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\test']);
|
||||
expect(s.isModified).toBe(true);
|
||||
expect(s.undoRedo.historyLength).toBe(1);
|
||||
});
|
||||
|
||||
it('addPath 追加到 userPaths', () => {
|
||||
useAppStore.getState().addPath('D:\\user', TargetType.USER);
|
||||
const s = useAppStore.getState();
|
||||
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\user']);
|
||||
expect(s.sysPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('editPath 替换正确位置', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('C:\\old', TargetType.SYSTEM);
|
||||
store.editPath(0, 'C:\\new', TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\new']);
|
||||
});
|
||||
|
||||
it('editPath 越界 index 无崩溃', () => {
|
||||
expect(() => {
|
||||
useAppStore.getState().editPath(99, 'X', TargetType.SYSTEM);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('deletePaths 单选删除', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.addPath('B', TargetType.SYSTEM);
|
||||
store.addPath('C', TargetType.SYSTEM);
|
||||
store.deletePaths([1], TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||
expect(useAppStore.getState().selectedIndices).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletePaths 多选删除(逆序排序一次 undo 覆盖)', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.USER);
|
||||
store.addPath('B', TargetType.USER);
|
||||
store.addPath('C', TargetType.USER);
|
||||
store.addPath('D', TargetType.USER);
|
||||
store.deletePaths([1, 3], TargetType.USER);
|
||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||
});
|
||||
|
||||
it('deletePaths 非连续多选删除后可 undo 恢复到正确位置', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.addPath('B', TargetType.SYSTEM);
|
||||
store.addPath('C', TargetType.SYSTEM);
|
||||
store.addPath('D', TargetType.SYSTEM);
|
||||
store.deletePaths([1, 3], TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'C']);
|
||||
useAppStore.getState().undo();
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('moveUp index=0 无操作', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.moveUp(0, TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('moveUp 正常交换位置', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.addPath('B', TargetType.SYSTEM);
|
||||
store.moveUp(1, TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['B', 'A']);
|
||||
expect(useAppStore.getState().selectedIndices).toEqual([0]);
|
||||
});
|
||||
|
||||
it('moveDown 末位无操作', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.USER);
|
||||
store.moveDown(0, TargetType.USER);
|
||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('cleanPaths 移除无效路径并返回 removed', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('C:\\valid', TargetType.SYSTEM);
|
||||
store.addPath(':::invalid:::', TargetType.SYSTEM);
|
||||
// is_valid_path_format 拒绝全标点路径
|
||||
const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::'));
|
||||
expect(removed).toEqual([':::invalid:::']);
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['C:\\valid']);
|
||||
});
|
||||
|
||||
it('replacePaths 整体替换列表', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('old1', TargetType.USER);
|
||||
store.addPath('old2', TargetType.USER);
|
||||
store.replacePaths(TargetType.USER, ['new1', 'new2', 'new3']);
|
||||
expect(useAppStore.getState().userPaths.map(e => e.path)).toEqual(['new1', 'new2', 'new3']);
|
||||
});
|
||||
|
||||
it('clearPaths 清空列表', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('A', TargetType.SYSTEM);
|
||||
store.addPath('B', TargetType.SYSTEM);
|
||||
store.clearPaths(TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('clearPaths 空列表无操作', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.clearPaths(TargetType.USER);
|
||||
expect(useAppStore.getState().undoRedo.historyLength).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo/redo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('undo 恢复操作前状态', () => {
|
||||
useAppStore.getState().addPath('test', TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().sysPaths.length).toBe(1);
|
||||
useAppStore.getState().undo();
|
||||
expect(useAppStore.getState().sysPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('redo 回到操作后状态', () => {
|
||||
const store = useAppStore.getState();
|
||||
store.addPath('test', TargetType.SYSTEM);
|
||||
store.undo();
|
||||
store.redo();
|
||||
expect(useAppStore.getState().sysPaths.map(e => e.path)).toEqual(['test']);
|
||||
});
|
||||
|
||||
it('undo/redo 正确更新 isModified', () => {
|
||||
const store = useAppStore.getState();
|
||||
// 设置已保存快照
|
||||
useAppStore.setState({ _savedSys: [], _savedUser: [] });
|
||||
store.addPath('test', TargetType.SYSTEM);
|
||||
expect(useAppStore.getState().isModified).toBe(true);
|
||||
store.undo();
|
||||
expect(useAppStore.getState().isModified).toBe(false);
|
||||
store.redo();
|
||||
expect(useAppStore.getState().isModified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPaths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('成功加载', async () => {
|
||||
mockedInvoke.mockResolvedValueOnce(['C:\\sys1', 'C:\\sys2']);
|
||||
mockedInvoke.mockResolvedValueOnce(['D:\\usr1']);
|
||||
await useAppStore.getState().loadPaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.sysPaths.map(e => e.path)).toEqual(['C:\\sys1', 'C:\\sys2']);
|
||||
expect(s.userPaths.map(e => e.path)).toEqual(['D:\\usr1']);
|
||||
expect(s.isLoading).toBe(false);
|
||||
expect(s.isModified).toBe(false);
|
||||
});
|
||||
|
||||
it('加载失败时 isLoading 重置', async () => {
|
||||
mockedInvoke.mockRejectedValueOnce(new Error('reg error'));
|
||||
mockedInvoke.mockResolvedValueOnce([]);
|
||||
await useAppStore.getState().loadPaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isLoading).toBe(false);
|
||||
expect(s.statusMessage).toContain('加载失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePaths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
useAppStore.setState({ sysPaths: [pe('A')], userPaths: [pe('B')] });
|
||||
});
|
||||
|
||||
it('保存成功', async () => {
|
||||
mockedInvoke.mockResolvedValue(undefined);
|
||||
await useAppStore.getState().savePaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isSaving).toBe(false);
|
||||
expect(s.isModified).toBe(false);
|
||||
expect(s.statusMessage).toBe('保存成功');
|
||||
});
|
||||
|
||||
it('部分失败时报告具体 hive', async () => {
|
||||
mockedInvoke
|
||||
.mockResolvedValueOnce(undefined) // backup_registry
|
||||
.mockResolvedValueOnce(undefined) // save_system_paths
|
||||
.mockRejectedValueOnce('权限不足'); // save_user_paths
|
||||
await useAppStore.getState().savePaths();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isSaving).toBe(false);
|
||||
expect(s.statusMessage).toContain('用户 PATH 保存失败');
|
||||
});
|
||||
|
||||
it('isSaving 守卫:并发第二次调用直接返回', async () => {
|
||||
let resolveAll: (v: unknown) => void;
|
||||
const pending = new Promise((r) => { resolveAll = r; });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockedInvoke.mockReturnValue(pending as any);
|
||||
|
||||
// 第一次调用(不等它完成,停在 Promise.allSettled)
|
||||
const p1 = useAppStore.getState().savePaths();
|
||||
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true)
|
||||
const r2 = useAppStore.getState().savePaths();
|
||||
|
||||
// 第二次调用同步返回 undefined(被守卫拦截)
|
||||
await expect(r2).resolves.toBeUndefined();
|
||||
|
||||
// 放行第一次调用的所有 invoke
|
||||
resolveAll!(undefined);
|
||||
await p1;
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('管理员模式初始化', async () => {
|
||||
mockedInvoke
|
||||
.mockResolvedValueOnce(true) // check_admin
|
||||
.mockResolvedValueOnce(['S1']) // load_system_paths
|
||||
.mockResolvedValueOnce(['U1']); // load_user_paths
|
||||
await useAppStore.getState().initialize();
|
||||
const s = useAppStore.getState();
|
||||
expect(s.isAdmin).toBe(true);
|
||||
expect(s.sysPaths.map(e => e.path)).toEqual(['S1']);
|
||||
expect(s.userPaths.map(e => e.path)).toEqual(['U1']);
|
||||
});
|
||||
|
||||
it('非管理员初始化进入只读模式', async () => {
|
||||
mockedInvoke
|
||||
.mockResolvedValueOnce(false) // check_admin
|
||||
.mockResolvedValueOnce([]) // load_system_paths
|
||||
.mockResolvedValueOnce([]); // load_user_paths
|
||||
await useAppStore.getState().initialize();
|
||||
expect(useAppStore.getState().isAdmin).toBe(false);
|
||||
// statusMessage 被后续 loadPaths 覆盖为加载完成消息,但 isAdmin=false 不变
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
detectExportFormat,
|
||||
flattenImportResult,
|
||||
} 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 = {
|
||||
system: ['C:\\Windows', 'C:\\Program Files'],
|
||||
user: ['C:\\Users\\me\\AppData'],
|
||||
system: [pe('C:\\Windows'), pe('C:\\Program Files')],
|
||||
user: [pe('C:\\Users\\me\\AppData')],
|
||||
};
|
||||
|
||||
describe('exportToJson', () => {
|
||||
@@ -21,15 +26,18 @@ describe('exportToJson', () => {
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.version).toBe('1.0');
|
||||
expect(parsed.type).toBe('PathEditor');
|
||||
expect(parsed.system).toEqual(sampleData.system);
|
||||
expect(parsed.user).toEqual(sampleData.user);
|
||||
expect(parsed.system).toEqual(sampleData.system.map(e => e.path));
|
||||
expect(parsed.user).toEqual(sampleData.user.map(e => e.path));
|
||||
expect(parsed.exported).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromJson', () => {
|
||||
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);
|
||||
expect(result.system).toEqual(sampleData.system);
|
||||
expect(result.user).toEqual(sampleData.user);
|
||||
@@ -38,7 +46,7 @@ describe('importFromJson', () => {
|
||||
it('过滤空字符串', () => {
|
||||
const json = JSON.stringify({ system: ['C:\\', '', ' '], user: [] });
|
||||
const result = importFromJson(json);
|
||||
expect(result.system).toEqual(['C:\\']);
|
||||
expect(result.system).toEqual([pe('C:\\')]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,13 +60,13 @@ describe('exportToCsv', () => {
|
||||
});
|
||||
|
||||
it('CSV 字段转义', () => {
|
||||
const data = { system: ['C:\\Path,with,commas'], user: [] };
|
||||
const data = { system: [pe('C:\\Path,with,commas')], user: [] };
|
||||
const csv = exportToCsv(data);
|
||||
expect(csv).toContain('"C:\\Path,with,commas"');
|
||||
});
|
||||
|
||||
it('CSV 双引号转义', () => {
|
||||
const data = { system: ['Path with "quotes"'], user: [] };
|
||||
const data = { system: [pe('Path with "quotes"')], user: [] };
|
||||
const csv = exportToCsv(data);
|
||||
expect(csv).toContain('"Path with ""quotes"""');
|
||||
});
|
||||
@@ -68,8 +76,8 @@ describe('importFromCsv', () => {
|
||||
it('正确导入 CSV', () => {
|
||||
const csv = 'type,path\nsystem,C:\\Windows\nuser,C:\\AppData\n';
|
||||
const result = importFromCsv(csv);
|
||||
expect(result.system).toEqual(['C:\\Windows']);
|
||||
expect(result.user).toEqual(['C:\\AppData']);
|
||||
expect(result.system).toEqual([pe('C:\\Windows')]);
|
||||
expect(result.user).toEqual([pe('C:\\AppData')]);
|
||||
});
|
||||
|
||||
it('跳过未知类型', () => {
|
||||
@@ -82,7 +90,7 @@ describe('importFromCsv', () => {
|
||||
it('处理带引号的 CSV 字段', () => {
|
||||
const csv = 'type,path\nsystem,"C:\\Path,With,Commas"';
|
||||
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('逐行导入,跳过注释和空行', () => {
|
||||
const txt = '# 这是注释\nC:\\Windows\n\nD:\\Projects\n# 另一个注释';
|
||||
const paths = importFromTxt(txt);
|
||||
expect(paths).toEqual(['C:\\Windows', 'D:\\Projects']);
|
||||
expect(paths).toEqual([pe('C:\\Windows'), pe('D:\\Projects')]);
|
||||
});
|
||||
|
||||
it('跳过 BOM', () => {
|
||||
const txt = 'C:\\Windows';
|
||||
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 txtContent = 'C:\\Test';
|
||||
|
||||
expect(importFromContent(csvContent, 'test.csv').system).toEqual(['C:\\Test']);
|
||||
expect(importFromContent(jsonContent, 'test.json').system).toEqual(['C:\\Test']);
|
||||
expect(importFromContent(txtContent, 'test.txt').system).toEqual(['C:\\Test']);
|
||||
expect(importFromContent(csvContent, 'test.csv').system).toEqual([pe('C:\\Test')]);
|
||||
expect(importFromContent(jsonContent, 'test.json').system).toEqual([pe('C:\\Test')]);
|
||||
expect(importFromContent(txtContent, 'test.txt').system).toEqual([pe('C:\\Test')]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,31 +124,32 @@ describe('detectExportFormat', () => {
|
||||
it('.csv 检测为 CSV', () => {
|
||||
expect(detectExportFormat('data.CSV')).toBe('csv');
|
||||
});
|
||||
|
||||
it('.txt 检测为 TXT', () => {
|
||||
expect(detectExportFormat('data.txt')).toBe('txt');
|
||||
});
|
||||
it('其他扩展名检测为 JSON', () => {
|
||||
expect(detectExportFormat('data.json')).toBe('json');
|
||||
expect(detectExportFormat('data.txt')).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenImportResult', () => {
|
||||
const data = { system: ['S1'], user: ['U1'] };
|
||||
const data = { system: [pe('S1')], user: [pe('U1')] };
|
||||
|
||||
it('仅系统', () => {
|
||||
const r = flattenImportResult(data, 'system');
|
||||
expect(r.system).toEqual(['S1']);
|
||||
expect(r.system).toEqual([pe('S1')]);
|
||||
expect(r.user).toEqual([]);
|
||||
});
|
||||
|
||||
it('仅用户', () => {
|
||||
const r = flattenImportResult(data, 'user');
|
||||
expect(r.system).toEqual([]);
|
||||
expect(r.user).toEqual(['U1']);
|
||||
expect(r.user).toEqual([pe('U1')]);
|
||||
});
|
||||
|
||||
it('两者都导入', () => {
|
||||
const r = flattenImportResult(data, 'both');
|
||||
expect(r.system).toEqual(['S1']);
|
||||
expect(r.user).toEqual(['U1']);
|
||||
expect(r.system).toEqual([pe('S1')]);
|
||||
expect(r.user).toEqual([pe('U1')]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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 validateFn = (path: string) => !path.includes('Invalid');
|
||||
|
||||
describe('pathClean', () => {
|
||||
it('移除无效路径', () => {
|
||||
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid'], validateFn);
|
||||
expect(kept).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||
expect(removed).toEqual(['C:\\Invalid']);
|
||||
const [kept, removed] = pathClean([pe('C:\\Valid'), pe('C:\\Invalid'), pe('D:\\Valid')], validateFn);
|
||||
expect(kept.map(e => e.path)).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||
expect(removed.map(e => e.path)).toEqual(['C:\\Invalid']);
|
||||
});
|
||||
|
||||
it('移除重复路径保留第一个', () => {
|
||||
const [kept, removed] = pathClean(['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(removed.length).toBe(1);
|
||||
});
|
||||
|
||||
it('全部有效无变化', () => {
|
||||
const [kept, removed] = pathClean(['C:\\a', 'D:\\b'], alwaysValid);
|
||||
expect(kept).toEqual(['C:\\a', 'D:\\b']);
|
||||
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
|
||||
expect(kept.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
|
||||
expect(removed.length).toBe(0);
|
||||
});
|
||||
|
||||
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(removed.length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('UndoRedoManager', () => {
|
||||
let mgr: UndoRedoManager;
|
||||
let sys: string[];
|
||||
let user: string[];
|
||||
let sys: PathEntry[];
|
||||
let user: PathEntry[];
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new UndoRedoManager(50);
|
||||
sys = ['C:\\Windows', 'C:\\Program Files'];
|
||||
user = ['C:\\Users\\me\\AppData'];
|
||||
sys = [pe('C:\\Windows'), pe('C:\\Program Files')];
|
||||
user = [pe('C:\\Users\\me\\AppData')];
|
||||
});
|
||||
|
||||
it('初始不可撤销不可重做', () => {
|
||||
@@ -22,14 +27,14 @@ describe('UndoRedoManager', () => {
|
||||
});
|
||||
|
||||
it('ADD 撤销/重做', () => {
|
||||
sys.push('C:\\NewPath');
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']));
|
||||
sys.push(pe('C:\\NewPath'));
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
|
||||
|
||||
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)!;
|
||||
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 撤销/重做', () => {
|
||||
@@ -38,21 +43,21 @@ describe('UndoRedoManager', () => {
|
||||
sys.splice(0, 1);
|
||||
|
||||
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)!;
|
||||
expect(r[0]).toEqual(['C:\\Program Files']);
|
||||
expect(r[0].map(e => e.path)).toEqual(['C:\\Program Files']);
|
||||
});
|
||||
|
||||
it('EDIT 撤销/重做', () => {
|
||||
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, ['C:\\Windows'], ['C:\\Edited']));
|
||||
sys[0] = 'C:\\Edited';
|
||||
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, [pe('C:\\Windows')], [pe('C:\\Edited')]));
|
||||
sys[0] = pe('C:\\Edited');
|
||||
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0][0]).toBe('C:\\Windows');
|
||||
expect(u[0][0].path).toBe('C:\\Windows');
|
||||
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0][0]).toBe('C:\\Edited');
|
||||
expect(r[0][0].path).toBe('C:\\Edited');
|
||||
});
|
||||
|
||||
it('MOVE_UP 撤销/重做', () => {
|
||||
@@ -60,10 +65,10 @@ describe('UndoRedoManager', () => {
|
||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||
|
||||
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)!;
|
||||
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 撤销/重做', () => {
|
||||
@@ -71,12 +76,12 @@ describe('UndoRedoManager', () => {
|
||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||
|
||||
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 撤销/重做', () => {
|
||||
const old = [...sys];
|
||||
const cleaned = ['C:\\Windows'];
|
||||
const cleaned = [pe('C:\\Windows')];
|
||||
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
|
||||
sys = cleaned;
|
||||
|
||||
@@ -101,7 +106,7 @@ describe('UndoRedoManager', () => {
|
||||
|
||||
it('IMPORT 撤销/重做', () => {
|
||||
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));
|
||||
sys = imported;
|
||||
|
||||
@@ -113,26 +118,60 @@ describe('UndoRedoManager', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('超出最大历史容量时移除最旧记录', () => {
|
||||
const small = new UndoRedoManager(3);
|
||||
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);
|
||||
});
|
||||
|
||||
it('操作 USER 路径', () => {
|
||||
user.push('C:\\NewUserPath');
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']));
|
||||
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
|
||||
// 扩展初始数组
|
||||
sys.push(pe('C:\\Extra1'), pe('C:\\Extra2'));
|
||||
const old = [...sys];
|
||||
// 删除 indices [1, 3](C:\Program Files 和 C:\Extra2)
|
||||
const removed = [sys[1], sys[3]];
|
||||
mgr.push({
|
||||
type: OperationType.DELETE, target: TargetType.SYSTEM,
|
||||
index: 1, count: 2,
|
||||
oldPaths: removed, newPaths: [],
|
||||
indices: [1, 3],
|
||||
});
|
||||
sys.splice(3, 1);
|
||||
sys.splice(1, 1);
|
||||
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[1]).toEqual(['C:\\Users\\me\\AppData']);
|
||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
expect(u[0]).toEqual(old);
|
||||
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Extra1']);
|
||||
});
|
||||
|
||||
it('操作 USER 路径', () => {
|
||||
user.push(pe('C:\\NewUserPath'));
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], [pe('C:\\NewUserPath')]));
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[1].map(e => e.path)).toEqual(['C:\\Users\\me\\AppData']);
|
||||
expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
});
|
||||
|
||||
it('TOGGLE 撤销/重做', () => {
|
||||
sys[0] = pe('C:\\Windows', false);
|
||||
mgr.push(makeRecord(OperationType.TOGGLE, TargetType.SYSTEM, 0, 1,
|
||||
[pe('C:\\Windows', true)], [pe('C:\\Windows', false)]));
|
||||
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0][0].enabled).toBe(true);
|
||||
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0][0].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['e2e/**', 'node_modules/**', 'src-tauri/**'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user