Compare commits

...

17 Commits

Author SHA1 Message Date
Serendipity 63c8ed424b docs: 添加 v4.1 bug 修复与代码清理的设计文档和实现计划
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:48:07 +08:00
Serendipity 605105da09 test: 移除 _markDirty 测试(函数已私有化,行为由 CRUD 测试间接覆盖)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:48:03 +08:00
Serendipity 68f4617bda fix: PathTable — 环境变量展开限流20并发、消除useEffect双重触发、类型断言改为常量
- expand useEffect 增加 .slice(0, 20) 批次限制,避免大量路径时并发过高
- validatedRef / expandedRef 替代 validationCache.has / expandedCache.has 过滤,
  从 useEffect 依赖数组中移除缓存 state,消除双重触发
- ValidationState 类型提升到模块层级,新增 DEFAULT_VALIDATION_STATE 常量
  替代硬编码类型断言

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:43:03 +08:00
Serendipity c30855fa70 refactor: backup.rs — use 语句移至文件顶部,注册表路径复用常量
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:42:15 +08:00
Serendipity 652280c2dd fix: AppShell 拖拽路径用 TauriFile 接口替代 as any
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:41:40 +08:00
Serendipity 2db872c661 docs: undo/redo 添加注释说明为何内联计算 isModified
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:41:38 +08:00
Serendipity 1ce3ebfd9e refactor: 代码清理 — 删除 AppError、重命名 replacePaths、修正 detectExportFormat、统一 PATH 长度、优化 BOM 检查、添加同步注释
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:14:13 +08:00
Serendipity 613fb51fd7 fix: 验证 IPC 异常时返回 unknown 状态,不再错误标记为有效路径
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:09:23 +08:00
Serendipity be375ed3ad fix: backup_registry 调用加 await,避免与 save 竞态导致备份到新值
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:08:05 +08:00
Serendipity 804e02004d fix: backup_registry 改为内部读取注册表当前值,不再依赖前端传入数据
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:04:16 +08:00
Serendipity 2775a3a588 fix: 非连续删除 undo 恢复到错误位置 — OpRecord 新增 indices 精确记录原始位置
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:57:21 +08:00
Serendipity 26ab52483a fix: 修复安装包缺少 WebView2Loader.dll 的问题
在 bundle.resources 中显式包含 WebView2Loader.dll,
GNU 工具链下 NSIS 打包器未自动检测此依赖。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:10:45 +08:00
Serendipity 775a570d31 feat: 导入改用原生对话框、新增 app-store 单元测试、修复窗口滚动
- handleImport 从 DOM <input> hack 改为 @tauri-apps/plugin-dialog 原生文件选择
- 新增 Rust read_text_file 命令读取文件内容,零外部依赖
- 新增 tests/unit/app-store.test.ts,25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths
- 修复 AppShell overflow-hidden 导致无法滚动窗口的问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:01:56 +08:00
Serendipity 41008e9282 chore: 从仓库移除 CLAUDE.md 和 .claude/ 内部配置文件
这些是 Claude Code 工具的内部配置文件,对开源项目用户无意义。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:27:16 +08:00
Serendipity 732b2aabaa docs: 重写 README.md 为开源项目风格
- 添加 badges(版本/Tauri/React/Rust/测试数)
- 按功能模块分组(路径管理/验证/撤销/导入导出/安全/界面)
- 补充安装章节(Releases + 源码构建)
- 补充贡献指南(开发环境、代码规范)
- 完整项目结构和快捷键表

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:22:35 +08:00
Serendipity 19bdb3078a docs: 重写 CLAUDE.md 反映最新项目状态
- 更新架构图(移除已删除的 string-list.ts,补充 Modal/buttons/ErrorBoundary)
- 新增数据模型章节(不可变 string[] + snapshot 比较 isModified)
- 新增撤销/重做 API 说明
- 新增错误处理章节(前端 + Rust 完整覆盖表)
- 补充 TypeScript strict 模式、Rust SAFETY 注释等质量约束

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:20:15 +08:00
Serendipity bdbb399ddc refactor: 清理 LOW 问题 — 样式去重、死代码删除、命名修正
- 抽取 buttons.ts 共享按钮样式,消除 3 个组件的重复定义
- store 删除未调用的 canUndo/canRedo 方法
- importFromContent 变量 ext→lower 修正确性
- CSV 导出修复 BOM 重复(exportToCsv 自带 BOM)
- Rust error.rs 添加 allow(dead_code) 消除编译警告

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:13:17 +08:00
30 changed files with 1852 additions and 389 deletions
-28
View File
@@ -1,28 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cmake --build build)",
"Bash(npm --version)",
"Bash(npm create *)",
"Bash(npm install *)",
"Bash(npx tauri *)",
"Bash(rm -f src/App.css src/index.css)",
"Bash(rm -rf src/assets)",
"Bash(npm run *)",
"Bash(npx tsc *)",
"Bash(npx vite *)",
"Bash(cargo check *)",
"Bash(rustup show *)",
"Bash(rustup toolchain *)",
"Bash(npx vitest *)",
"Bash(cargo build *)",
"Bash(where link.exe)",
"Bash(dir \"C:\\\\Program Files\\\\Microsoft Visual Studio\")",
"Bash(dir \"C:\\\\Program Files \\(x86\\)\\\\Microsoft Visual Studio\")",
"Bash(rustup override *)",
"Bash(dir *)",
"Bash(cargo clean *)",
"Bash(\"D:/settings/Language/Rust/mingw64/bin/gcc.exe\" --version)"
]
}
}
+2 -1
View File
@@ -22,4 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.claude/worktrees/
.claude/
CLAUDE.md
-95
View File
@@ -1,95 +0,0 @@
# CLAUDE.md
## 项目概述
PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器,使用 Tauri 2.x + React 19 + TypeScript + Rust 构建。
## 构建命令
```bash
# 安装前端依赖
npm install
# 开发模式(热更新)
npx tauri dev
# 仅前端
npm run dev
# 前端测试
npm test
npm run test:watch
# 构建生产版本
npm run build
# Rust 后端检查
cd src-tauri && cargo check
# Rust 后端测试
cd src-tauri && cargo test
# 完整构建(安装包)
npx tauri build
```
## 架构
前后端分离,通过 Tauri IPC 通信:
```
src/ # React 前端 (TypeScript)
├── core/ # 纯逻辑 — 零 React 依赖
│ ├── string-list.ts # StringList 数据结构
│ ├── undo-redo.ts # 撤销/重做管理器(8 种操作类型)
│ ├── path-manager.ts # 路径增删移清理
│ ├── import-export.ts # JSON/CSV/TXT 导入导出
│ └── validation.ts # 路径格式验证
├── store/ # Zustand 状态管理
│ ├── app-store.ts # 主状态(路径、撤销、CRUD、加载/保存)
│ └── theme-store.ts # 深色/浅色模式
├── components/ # React UI 组件
│ ├── layout/ # AppShell、TitleBar、StatusBar
│ ├── path-list/ # PathTable、MergePreview
│ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons、SearchInput
│ └── dialogs/ # PathEditDialog、HelpDialog、ImportDialog
├── hooks/ # use-keyboard、use-path-validation
├── i18n/ # i18next 中英文翻译
└── config/ # default.json UI 参数配置
src-tauri/ # Tauri Rust 后端
├── src/
│ ├── commands/
│ │ ├── registry.rs # 注册表读写(load/save system & user paths
│ │ ├── system.rs # check_admin、validate_path、expand_env_vars、broadcast
│ │ └── backup.rs # backup_registry、get_appdata_dir
│ ├── error.rs
│ └── lib.rs # 注册所有 IPC commands
├── Cargo.toml
└── tauri.conf.json
tests/unit/ # Vitest 前端单元测试
```
## IPC 接口(Rust → Frontend
| Command | 功能 |
|---------|------|
| `load_system_paths` | 从 HKLM 注册表读取系统 PATH |
| `load_user_paths` | 从 HKCU 注册表读取用户 PATH |
| `save_system_paths` | 保存系统 PATH 到注册表 |
| `save_user_paths` | 保存用户 PATH 到注册表 |
| `check_admin` | 检测管理员权限 |
| `validate_path` | 验证路径目录是否存在 |
| `expand_env_vars` | 展开 %VAR% 环境变量 |
| `broadcast_env_change` | 广播 WM_SETTINGCHANGE |
| `backup_registry` | 备份注册表 PATH 到文件 |
| `get_appdata_dir` | 获取备份目录路径 |
## 关键约束
- Rust 工具链:`stable-x86_64-pc-windows-gnu`(项目已设 override
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
- 运行需要管理员权限才能编辑系统 PATH
- `cargo test` 需要 MinGW bin 在 PATH 中(GCC 15.2.0 运行时依赖 `libmcfgthread-2.dll`),开发模式下可用 `npx tauri dev` 替代
+136 -62
View File
@@ -1,88 +1,94 @@
# PathEditor v4.0
<p align="center">
<h1>PathEditor</h1>
<p>Windows 系统环境变量 (PATH) 编辑器</p>
</p>
Windows 系统环境变量 (PATH) 编辑器,基于 Tauri 2.x + React 19 + TypeScript + Rust 构建。
<p align="center">
<img src="https://img.shields.io/badge/version-4.0.0-blue" alt="version">
<img src="https://img.shields.io/badge/tauri-2.x-ffa03a" alt="tauri">
<img src="https://img.shields.io/badge/react-19-61dafb" alt="react">
<img src="https://img.shields.io/badge/rust-1.95-000000" alt="rust">
<img src="https://img.shields.io/badge/typescript-strict-blue" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green" alt="license">
<img src="https://img.shields.io/badge/tests-55%20passed-brightgreen" alt="tests">
</p>
---
## 简介
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
v4.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
## 截图
_[待补充]_
## 功能
- 查看和编辑系统/用户 PATH 环境变量
### 路径管理
- 查看和编辑 **系统 PATH**HKLM)和 **用户 PATH**HKCU
- 新建、编辑、删除、上移、下移路径条目
- 一键清理无效和重复路径
- 完整撤销/重做支持(最多 50 步)
- 导入/导出 JSON、CSV、TXT 三种格式
- 深色模式 / 浅色模式切换
- 中英文界面切换
- 合并预览(同时查看系统 + 用户路径)
- 搜索过滤
- 多选批量删除
- 实时搜索过滤
- 合并预览(系统 + 用户路径并列显示)
- 文件夹拖拽添加
- 注册表备份
## 运行
### 路径验证
- **红色**标记:路径在文件系统中不存在
- **橙色**标记:路径在列表中重复出现
- 环境变量路径(含 `%VAR%`)悬浮展开预览
需要管理员权限才能编辑系统 PATH(非管理员自动进入只读模式)。
### 撤销/重做
- 支持 8 种操作类型,最多 50 步历史
- 新增、删除、编辑、移动、清理、清空、导入均可撤销
### 导入/导出
- **JSON**:结构化导出,含版本和时间戳
- **CSV**UTF-8 BOM 编码,兼容 Excel
- **TXT**:纯文本,每行一个路径
### 安全
- 保存前自动备份注册表到 `%APPDATA%/PathEditor/backups/`
- PATH 长度检查(Windows 单变量上限 32767 字符)
- 非管理员自动进入**只读模式**
- 保存中途失败精确提示哪个注册表 hive 出错
### 界面
- 深色模式 / 浅色模式
- 中文 / English 界面切换
- 全局键盘快捷键
- 修改状态指示(黄点)+ 未保存退出确认
## 安装
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.0.0_x64-setup.exe` 安装。
或从源码构建:
```bash
# 安装依赖
npm install
# 开发模式(热更新)
npx tauri dev
# 构建安装包
npx tauri build
```
## 技术栈
| 层 | 技术 |
|---|---|
| 前端框架 | React 19 + TypeScript |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 后端语言 | Rust |
| 测试 | Vitest (前端) |
| 构建 | Vite |
## 架构
```
src/ # React 前端
├── core/ # 纯逻辑(StringList、撤销/重做、路径管理、导入导出)
├── store/ # Zustand 状态管理
├── components/ # UI 组件(列表、工具栏、对话框)
├── hooks/ # 自定义 Hooks(键盘快捷键、路径验证)
├── i18n/ # 中英文翻译
└── config/ # UI 参数配置
src-tauri/ # Rust 后端
└── src/commands/
├── registry.rs # 注册表读写
├── system.rs # 权限检测、路径验证、环境变量展开、系统广播
└── backup.rs # 注册表备份
```
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| Ctrl+N | 新建路径 |
| Ctrl+S | 保存 |
| Ctrl+Z | 撤销 |
| Ctrl+Y | 重做 |
| Ctrl+F | 搜索 |
| Delete | 删除选中 |
| F1 | 帮助 |
> **要求**Windows 10+(自带 WebView2),管理员权限才能编辑系统 PATH。
## 开发
```bash
# 开发模式(热更新)
npx tauri dev
# 仅前端
npm run dev
# 前端测试
npm test
# 前端测试(监听模式)
npm run test:watch
# Rust 后端检查
cd src-tauri && cargo check
@@ -90,10 +96,78 @@ cd src-tauri && cargo check
cd src-tauri && cargo test
```
### 技术栈
| 层 | 技术 |
|---|---|
| 前端框架 | React 19 + TypeScript (strict) |
| UI 样式 | Tailwind CSS 4 |
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 后端 | Rust (winreg + windows-rs FFI) |
| 前端测试 | Vitest (45 个测试) |
| Rust 测试 | cargo test (10 个测试) |
| 构建 | Vite |
| 打包 | NSIS |
### 项目结构
```
src/ # React 前端
├── core/ # 纯逻辑 — 零框架依赖、零平台依赖
├── store/ # Zustand 状态管理
├── components/
│ ├── layout/ # AppShell、TitleBar、StatusBar、ErrorBoundary
│ ├── path-list/ # PathTable、MergePreview
│ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons、SearchInput
│ ├── dialogs/ # PathEditDialog、HelpDialog、ImportDialog
│ └── ui/ # Modal、buttons(共享组件)
├── hooks/ # useAppActions、useKeyboard
├── i18n/ # zh-CN / en
└── config/ # default.json
src-tauri/ # Rust 后端
└── src/commands/
├── registry.rs # 注册表读写
├── system.rs # 权限检测、路径验证、环境变量展开
└── backup.rs # 注册表备份
tests/unit/ # 前端单元测试
```
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+N` | 新建路径 |
| `Ctrl+S` | 保存 |
| `Ctrl+Z` | 撤销 |
| `Ctrl+Y` | 重做 |
| `Ctrl+F` | 搜索 |
| `Delete` | 删除选中 |
| `F1` | 帮助 |
## 贡献
欢迎提交 Issue 和 Pull Request。在开始大改动前,建议先开 Issue 讨论。
### 本地开发环境
- Node.js 22+
- Rust 1.95+ (stable-x86_64-pc-windows-gnu)
- MinGW-w64 (GCC 15.x 需配置 `-lmcfgthread` 链接标志)
### 代码规范
- TypeScript `strict: true`,零编译错误
- 所有 Rust `unsafe` 块必须有 `// SAFETY:` 注释
- 前端核心逻辑在 `src/core/`,纯函数,零依赖,可独立测试
## 许可证
MIT License
## 作者
刘航宇 — [GitHub](https://github.com/LHY0125/PathEditor)
[刘航宇](https://github.com/LHY0125) — 河南理工大学人工智能协会
@@ -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,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` 函数处各加一行注释,提醒修改时同步两端。
Binary file not shown.
+23 -11
View File
@@ -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();
+5
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod registry;
pub mod system;
pub mod backup;
pub mod fs;
+9 -7
View File
@@ -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())
-35
View File
@@ -1,35 +0,0 @@
use serde::Serialize;
/// 传给前端的统一错误类型(保留供未来使用,当前命令返回 Result<T, String>
#[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 -1
View File
@@ -1,5 +1,4 @@
mod commands;
mod error;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -26,6 +25,7 @@ pub fn run() {
commands::system::broadcast_env_change,
commands::backup::backup_registry,
commands::backup::get_appdata_dir,
commands::fs::read_text_file,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1
View File
@@ -28,6 +28,7 @@
"bundle": {
"active": true,
"targets": "nsis",
"resources": ["WebView2Loader.dll"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+8 -3
View File
@@ -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);
}
}
}}
+29 -31
View File
@@ -1,4 +1,4 @@
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';
@@ -11,6 +11,9 @@ interface PathRow {
index: number;
}
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,11 +25,14 @@ 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 }));
@@ -42,50 +48,44 @@ 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));
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];
if (p.includes('%')) return [p, 'valid'];
const valid: boolean = await invoke('validate_path', { path: p });
return [p, valid];
return [p, valid ? 'valid' : 'invalid'];
} catch {
return [p, true];
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);
}
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.includes('%') && !expandedRef.current.has(p),
);
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 : ''];
@@ -95,21 +95,18 @@ export function PathTable({ tabId }: PathTableProps) {
}),
).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 +114,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('%'),
};
@@ -168,8 +165,9 @@ export function PathTable({ tabId }: PathTableProps) {
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)';
return (
<tr
+1 -8
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '@/store/app-store';
import { btnClass, btnStyle } from '@/components/ui/buttons';
interface ActionButtonsProps {
onNew: () => void;
@@ -24,14 +25,6 @@ export function ActionButtons({
const isAdmin = useAppStore((s) => s.isAdmin);
const disabled = !isAdmin;
const btnClass =
'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const btnStyle = {
backgroundColor: 'var(--app-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
};
return (
<div className="flex gap-1 flex-wrap">
<button className={btnClass} style={btnStyle} disabled={disabled} onClick={onNew}>
+12 -19
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '@/store/app-store';
import { btnClass, btnStyle } from '@/components/ui/buttons';
import { SearchInput } from './SearchInput';
import { ActionButtons } from './ActionButtons';
import { UndoRedoButtons } from './UndoRedoButtons';
@@ -26,14 +27,6 @@ export function ToolBar(props: ToolBarProps) {
const isAdmin = useAppStore((s) => s.isAdmin);
const isModified = useAppStore((s) => s.isModified);
const sysBtnClass =
'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const sysBtnStyle = {
backgroundColor: 'var(--app-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
};
return (
<div className="space-y-2 pb-2 border-b" style={{ borderColor: 'var(--app-border)' }}>
{/* 第一行: 搜索 + 系统按钮 */}
@@ -42,38 +35,38 @@ export function ToolBar(props: ToolBarProps) {
<div className="flex-1" />
<UndoRedoButtons />
<button
className={sysBtnClass}
style={sysBtnStyle}
className={btnClass}
style={btnStyle}
disabled={!isAdmin}
onClick={props.onImport}
>
{t('button.import')}
</button>
<button className={sysBtnClass} style={sysBtnStyle} onClick={props.onExport}>
<button className={btnClass} style={btnStyle} onClick={props.onExport}>
{t('button.export')}
</button>
<button
className={sysBtnClass}
className={btnClass}
style={{
...sysBtnStyle,
backgroundColor: isModified ? '#2563eb' : sysBtnStyle.backgroundColor,
color: isModified ? '#fff' : sysBtnStyle.color,
...btnStyle,
backgroundColor: isModified ? '#2563eb' : btnStyle.backgroundColor,
color: isModified ? '#fff' : btnStyle.color,
}}
disabled={!isAdmin}
onClick={props.onSave}
>
{t('button.save')}
</button>
<button className={sysBtnClass} style={sysBtnStyle} onClick={props.onCancel}>
<button className={btnClass} style={btnStyle} onClick={props.onCancel}>
{t('button.cancel')}
</button>
<button className={sysBtnClass} style={sysBtnStyle} onClick={props.onHelp}>
<button className={btnClass} style={btnStyle} onClick={props.onHelp}>
{t('button.help')}
</button>
<button className={sysBtnClass} style={sysBtnStyle} onClick={props.onLanguage}>
<button className={btnClass} style={btnStyle} onClick={props.onLanguage}>
{t('button.language')}
</button>
<button className={sysBtnClass} style={sysBtnStyle} onClick={props.onDarkMode}>
<button className={btnClass} style={btnStyle} onClick={props.onDarkMode}>
{t('button.darkMode')}
</button>
</div>
+1 -8
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '@/store/app-store';
import { btnClass, btnStyle } from '@/components/ui/buttons';
export function UndoRedoButtons() {
const { t } = useTranslation();
@@ -8,14 +9,6 @@ export function UndoRedoButtons() {
const undo = useAppStore((s) => s.undo);
const redo = useAppStore((s) => s.redo);
const btnClass =
'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const btnStyle = {
backgroundColor: 'var(--app-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
};
return (
<div className="flex gap-1">
<button
+7
View File
@@ -0,0 +1,7 @@
export const btnClass = 'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
export const btnStyle: React.CSSProperties = {
backgroundColor: 'var(--app-bg)',
color: 'var(--app-fg)',
borderColor: 'var(--app-border)',
};
+3 -3
View File
@@ -12,8 +12,8 @@
"dir": ""
},
"path": {
"maxSystemLength": 2048,
"maxUserLength": 2048,
"maxCombinedLength": 8191
"maxSystemLength": 32767,
"maxUserLength": 32767,
"maxCombinedLength": 32767
}
}
+15 -12
View File
@@ -2,7 +2,7 @@
* 导入导出模块 — 对应 C 版 import_export.c
* 支持 JSON、CSV、TXT 三种格式
*/
export type ExportFormat = 'json' | 'csv';
export type ExportFormat = 'json' | 'csv' | 'txt';
export interface ExportData {
system: string[];
@@ -11,7 +11,9 @@ export interface ExportData {
/** 根据文件扩展名检测格式 */
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';
}
@@ -65,10 +67,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);
}
@@ -174,9 +176,10 @@ export function importFromTxt(content: string): string[] {
const paths: string[] = [];
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;
@@ -193,10 +196,10 @@ export function importFromContent(
content: string,
filepath: string,
): ImportResult {
const ext = filepath.toLowerCase();
if (ext.endsWith('.csv')) {
const lower = filepath.toLowerCase();
if (lower.endsWith('.csv')) {
return importFromCsv(content);
} else if (ext.endsWith('.json')) {
} else if (lower.endsWith('.json')) {
return importFromJson(content);
} else {
// TXT 文件:所有路径放入 system(用户后续可选择目标)
+18 -4
View File
@@ -17,6 +17,8 @@ export interface OpRecord {
count: number;
oldPaths: string[];
newPaths: string[];
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
indices?: number[];
}
const DEFAULT_MAX_SIZE = 50;
@@ -54,8 +56,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:
@@ -95,8 +103,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:
+2 -1
View File
@@ -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(';')
+19 -22
View File
@@ -2,6 +2,7 @@ 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 { is_valid_path_format } from '@/core/validation';
import { useKeyboard } from './use-keyboard';
@@ -80,25 +81,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);
} else if (result.user.length > 0) {
useAppStore.getState().replacePaths(TargetType.USER, result.user);
}
}, [setImportDialog]);
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
@@ -108,7 +105,7 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const content = isCsv ? exportToCsv(data) : exportToJson(data);
const mime = isCsv ? 'text/csv' : 'application/json';
const ext = isCsv ? '.csv' : '.json';
const blob = new Blob([isCsv ? '' : '', content], { type: mime });
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -162,8 +159,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);
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user);
setImportDialog({ open: false, system: [], user: [] });
}, [dialogs.importDialog, setImportDialog]);
+34 -36
View File
@@ -34,27 +34,30 @@ 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;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
loadPaths: () => Promise<void>;
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]);
}
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: [],
@@ -84,7 +87,7 @@ export const useAppStore = create<AppState>((set, get) => ({
});
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
get()._markDirty();
markDirty();
},
editPath: (index, newPath, target) => {
@@ -100,28 +103,29 @@ export const useAppStore = create<AppState>((set, get) => ({
newList[index] = newPath;
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) => {
@@ -135,7 +139,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) => {
@@ -149,7 +153,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) => {
@@ -164,25 +168,25 @@ 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;
},
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;
state.undoRedo.push({
type: OperationType.IMPORT, target, index: 0, count: importPaths.length,
oldPaths: [...list], newPaths: [...importPaths],
type: OperationType.IMPORT, target, index: 0, count: newPaths.length,
oldPaths: [...list], newPaths: [...newPaths],
});
if (target === TargetType.SYSTEM) set({ sysPaths: [...importPaths], selectedIndices: [] });
else set({ userPaths: [...importPaths], selectedIndices: [] });
get()._markDirty();
if (target === TargetType.SYSTEM) set({ sysPaths: [...newPaths], selectedIndices: [] });
else set({ userPaths: [...newPaths], selectedIndices: [] });
markDirty();
},
clearPaths: (target) => {
@@ -197,7 +201,7 @@ export const useAppStore = create<AppState>((set, get) => ({
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
else set({ userPaths: [] });
get()._markDirty();
markDirty();
},
undo: () => {
@@ -206,6 +210,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)),
});
}
@@ -217,19 +222,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)) });
},
canUndo: () => get().undoRedo.canUndo(),
canRedo: () => get().undoRedo.canRedo(),
loadPaths: async () => {
try {
set({ isLoading: true });
@@ -263,8 +261,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([
@@ -297,4 +295,4 @@ export const useAppStore = create<AppState>((set, get) => ({
}
await get().loadPaths();
},
}));
};});
+300
View File
@@ -0,0 +1,300 @@
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 { 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).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).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).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).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).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).toEqual(['A', 'C']);
useAppStore.getState().undo();
expect(useAppStore.getState().sysPaths).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).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).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).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).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).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).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).toEqual(['C:\\sys1', 'C:\\sys2']);
expect(s.userPaths).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: ['A'], userPaths: ['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; });
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).toEqual(['S1']);
expect(s.userPaths).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 不变
});
});
+3 -2
View File
@@ -116,10 +116,11 @@ 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');
});
});
+22
View File
@@ -128,6 +128,28 @@ describe('UndoRedoManager', () => {
expect(small.historyLength).toBe(3);
});
it('非连续多选 DELETE 撤销恢复到原始位置', () => {
// 扩展初始数组
sys.push('C:\\Extra1', '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[0]).toEqual(old);
const r = mgr.redo(...u)!;
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Extra1']);
});
it('操作 USER 路径', () => {
user.push('C:\\NewUserPath');
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']));