diff --git a/docs/superpowers/plans/2026-05-26-v4.1-bugfix-and-cleanup-plan.md b/docs/superpowers/plans/2026-05-26-v4.1-bugfix-and-cleanup-plan.md new file mode 100644 index 0000000..90e473c --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-v4.1-bugfix-and-cleanup-plan.md @@ -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, 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 { +``` + +```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) -> Result { + 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>(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(); + 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) => 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((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((set, get) => ({...}))` 改为 `create((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 { +``` + +```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 +``` diff --git a/docs/superpowers/plans/2026-05-26-v4.1-round2-cleanup-plan.md b/docs/superpowers/plans/2026-05-26-v4.1-round2-cleanup-plan.md new file mode 100644 index 0000000..499787d --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-v4.1-round2-cleanup-plan.md @@ -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>(new Set()); +const expandedRef = useRef>(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 +``` diff --git a/docs/superpowers/specs/2026-05-26-v4.1-bugfix-and-cleanup-design.md b/docs/superpowers/specs/2026-05-26-v4.1-bugfix-and-cleanup-design.md new file mode 100644 index 0000000..d4120f8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-v4.1-bugfix-and-cleanup-design.md @@ -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` 改为 `Map`。IPC 异常时存 `'unknown'`,UI 渲染为默认色(不标绿也不标红)。 + +--- + +## 代码清理 + +### C1. 删除 AppError 死代码 + +删除 `src-tauri/src/error.rs`,移除 `lib.rs` 中的 `mod error`。 + +理由:全部 IPC 命令使用 `Result`,`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` 函数处各加一行注释,提醒修改时同步两端。