mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:45:54 +08:00
build, fix, feat, refactor: 优化长列表性能,新增注册表并发校验,升级v5.1.0
- 前端引入@tanstack/react-virtual虚拟列表库,重构PathTable与MergePreview组件,优化大量路径条目下的渲染性能 - 为后端注册表保存接口添加原始路径比对逻辑,防止并发修改导致的配置覆盖,同步更新前端保存逻辑传递原始路径参数 - 替换core模块手动编写的Windows API FFI声明为windows-sys官方库,简化代码维护 - 完善单元测试,新增空数组处理、边界场景的测试用例 - 更新项目依赖与锁定文件,将版本升级至v5.1.0 - 新增项目代码架构审查文档
This commit is contained in:
Generated
+1
@@ -2386,6 +2386,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
"winreg 0.52.0",
|
"winreg 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ log = "0.4"
|
|||||||
winreg = "0.52"
|
winreg = "0.52"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
windows-sys = { version = "0.61.2", features = ["Win32_System_Environment", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
|
||||||
|
|||||||
+7
-24
@@ -1,3 +1,7 @@
|
|||||||
|
use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
|
||||||
|
};
|
||||||
use winreg::enums::*;
|
use winreg::enums::*;
|
||||||
use winreg::RegKey;
|
use winreg::RegKey;
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ pub fn validate_path(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin)
|
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin)
|
||||||
|
/// 包含 % 的路径(环境变量路径)无法展开,返回原始路径
|
||||||
pub fn expand_env_vars(path: &str) -> String {
|
pub fn expand_env_vars(path: &str) -> String {
|
||||||
if !path.contains('%') {
|
if !path.contains('%') {
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
@@ -46,7 +51,7 @@ pub fn expand_env_vars(path: &str) -> String {
|
|||||||
|
|
||||||
// SAFETY: buffer 容量为 required(API 返回的精确大小),wide_path 以 null 结尾,
|
// SAFETY: buffer 容量为 required(API 返回的精确大小),wide_path 以 null 结尾,
|
||||||
// 且两个指针指向不同的内存区域,不存在重叠
|
// 且两个指针指向不同的内存区域,不存在重叠
|
||||||
let mut buffer: Vec<u16> = vec![0; required as usize];
|
let mut buffer = vec![0_u16; required as usize];
|
||||||
let result =
|
let result =
|
||||||
unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) };
|
unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) };
|
||||||
|
|
||||||
@@ -73,10 +78,6 @@ fn decode_utf16_preserving(v: &[u16]) -> String {
|
|||||||
/// 广播环境变量更改通知(WM_SETTINGCHANGE)
|
/// 广播环境变量更改通知(WM_SETTINGCHANGE)
|
||||||
/// 广播 `WM_SETTINGCHANGE` 通知系统环境变量已变更
|
/// 广播 `WM_SETTINGCHANGE` 通知系统环境变量已变更
|
||||||
pub fn broadcast_env_change() {
|
pub fn broadcast_env_change() {
|
||||||
const HWND_BROADCAST: isize = 0xFFFF;
|
|
||||||
const WM_SETTINGCHANGE: u32 = 0x001A;
|
|
||||||
const SMTO_ABORTIFHUNG: u32 = 0x0002;
|
|
||||||
|
|
||||||
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
|
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
|
||||||
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
|
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ pub fn broadcast_env_change() {
|
|||||||
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
|
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
SendMessageTimeoutW(
|
SendMessageTimeoutW(
|
||||||
HWND_BROADCAST,
|
HWND_BROADCAST as _,
|
||||||
WM_SETTINGCHANGE,
|
WM_SETTINGCHANGE,
|
||||||
0,
|
0,
|
||||||
env_str.as_ptr() as isize,
|
env_str.as_ptr() as isize,
|
||||||
@@ -101,24 +102,6 @@ pub fn broadcast_env_change() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 外部 FFI 声明 ──
|
|
||||||
|
|
||||||
extern "system" {
|
|
||||||
/// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-expandenvironmentstringsw
|
|
||||||
fn ExpandEnvironmentStringsW(lpSrc: *const u16, lpDst: *mut u16, nSize: u32) -> u32;
|
|
||||||
|
|
||||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw
|
|
||||||
fn SendMessageTimeoutW(
|
|
||||||
hWnd: isize,
|
|
||||||
Msg: u32,
|
|
||||||
wParam: usize,
|
|
||||||
lParam: isize,
|
|
||||||
fuFlags: u32,
|
|
||||||
uTimeout: u32,
|
|
||||||
lpdwResult: *mut usize,
|
|
||||||
) -> isize;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# PathEditor v5.0 代码与架构审查报告
|
||||||
|
|
||||||
|
## 1. 项目概览
|
||||||
|
|
||||||
|
PathEditor v5.0 是一个功能完善的 Windows 系统环境变量 (PATH) 编辑器,支持 GUI 与 CLI 双模式。
|
||||||
|
技术栈选型现代化且合理:
|
||||||
|
- **后端 / 核心逻辑**:Rust (Cargo Workspace)
|
||||||
|
- **GUI 框架**:Tauri 2.x
|
||||||
|
- **前端**:React 19 + TypeScript + Zustand
|
||||||
|
|
||||||
|
整体项目结构清晰,职责划分明确,严格遵循了前后端分离与核心逻辑无平台依赖的设计原则。
|
||||||
|
|
||||||
|
## 2. 架构设计审查
|
||||||
|
|
||||||
|
### 2.1 Cargo Workspace 三层架构
|
||||||
|
项目采用了经典的 Cargo Workspace 模式,分为三层:
|
||||||
|
- `core`: 纯 Rust 库 crate,包含所有的核心业务逻辑(注册表读写、备份、配置文件管理、路径验证与清理等)。该层**完全不依赖** Tauri 或 CLI 库,极大地提高了代码的复用性和可测试性。
|
||||||
|
- `gui`: Tauri 桌面应用。仅作为薄包装层(Thin Wrapper),通过 `#[tauri::command]` 将 `core` 的功能暴露为 IPC 接口供前端调用。
|
||||||
|
- `cli`: 命令行工具层。依赖 `core` 和 `clap` 库,直接提供命令行交互能力。
|
||||||
|
|
||||||
|
**审查结论**:架构设计非常优秀。核心逻辑解耦彻底,无论是 GUI 还是 CLI 都能复用同一套安全、经过测试的核心代码。
|
||||||
|
|
||||||
|
### 2.2 IPC 通信与状态同步
|
||||||
|
前端与 Rust 后端通过 Tauri IPC 进行通信。
|
||||||
|
- 所有的错误处理均通过 `Result<T, String>` 返回,前端通过 `Promise` 捕获并处理,用户体验良好。
|
||||||
|
- 针对非事务性的双写操作(如同时保存系统和用户 PATH),前端 `app-store.ts` 中使用了 `Promise.allSettled`。当发生部分成功(Partial Success)时,能正确捕获并重新加载注册表状态,避免了前端内存状态与后端注册表状态的漂移(State Drift)。
|
||||||
|
|
||||||
|
## 3. 后端代码审查 (Rust)
|
||||||
|
|
||||||
|
### 3.1 核心逻辑 (`core`)
|
||||||
|
- **安全性与健壮性**:
|
||||||
|
- 在 `registry.rs` 中,严格检查了路径字符串的 Null 字节,以及 32767 个字符的 Windows 注册表长度上限,防止缓冲区溢出或写入失败。
|
||||||
|
- 使用了安全的 `winreg` 库进行注册表操作。
|
||||||
|
- **FFI 调用**:
|
||||||
|
- 在 `system.rs` 中调用 Windows API(如 `ExpandEnvironmentStringsW` 和 `SendMessageTimeoutW`)时,对 `unsafe` 代码块进行了详尽的 SAFETY 注释。
|
||||||
|
- 能够妥善处理 UTF-16 编码和解码,保留非法码点避免丢失路径信息,细节处理非常到位。
|
||||||
|
|
||||||
|
### 3.2 命令行工具 (`cli`)
|
||||||
|
- **原子性与并发安全**:
|
||||||
|
- 在 CLI 的 `verify_and_save` 逻辑中,写入前会重新读取注册表并与原始状态对比。如果不一致,则拒绝写入并报错退出。这有效地防止了并发情况下的配置覆盖问题。
|
||||||
|
- **用户体验**:
|
||||||
|
- 命令设计符合直觉,支持 `--dry-run` 预览以及 JSON 格式输出,方便与其他脚本集成。
|
||||||
|
|
||||||
|
## 4. 前端代码审查 (React + TypeScript)
|
||||||
|
|
||||||
|
### 4.1 状态管理 (`app-store.ts`)
|
||||||
|
- 使用 `Zustand` 进行全局状态管理,状态树设计合理,避免了 React Context 可能带来的不必要重渲染。
|
||||||
|
- 实现了完善的 `UndoRedoManager`,将每一步操作抽象为 `OperationType`,支持撤销/重做功能,这对于编辑器类应用来说是核心体验的加分项。
|
||||||
|
- `isSaving` 状态守卫有效防止了用户双击保存按钮引发的并发竞争。
|
||||||
|
|
||||||
|
### 4.2 UI 与逻辑分离
|
||||||
|
- 业务逻辑抽象到 `src/core` 目录下(如 `path-manager.ts`, `validation.ts`),UI 组件仅负责渲染和事件绑定。
|
||||||
|
- `useAppActions.ts` 钩子巧妙地将组件层与 Store 状态操作解耦,使得组件代码极其整洁。
|
||||||
|
|
||||||
|
## 5. 改进建议 (Recommendations)
|
||||||
|
|
||||||
|
虽然当前代码质量已经很高,但仍有以下几个方面可以进一步优化:
|
||||||
|
|
||||||
|
1. **Rust FFI 维护性**:
|
||||||
|
当前 `system.rs` 中手动声明了 `extern "system"` 函数。建议引入 `windows-rs` 或 `windows-sys` 库,这能提供微软官方维护的安全的 API 绑定,减少手动编写 FFI 签名带来的维护成本和潜在错误。
|
||||||
|
2. **GUI 保存的并发安全 (Race Condition)**:
|
||||||
|
CLI 已经实现了保存前的二次状态比对(`verify_and_save`),但在 `gui/src/commands/registry.rs` 中,直接调用了 `save_system_paths`。如果在用户打开 GUI 修改期间,另一个进程修改了注册表,GUI 保存时可能会覆盖该修改。建议在 GUI 的 IPC 保存接口中,也引入类似 CLI 的版本校验(例如传入 `expected_original_paths` 进行比对)。
|
||||||
|
3. **前端单元测试覆盖**:
|
||||||
|
核心逻辑如 `undo-redo.ts` 和 `path-manager.ts` 纯函数特性明显,建议在 `tests/unit/` 下增加对这些文件的边界用例测试,确保复杂编辑操作下状态不崩溃。
|
||||||
|
4. **长列表性能**:
|
||||||
|
如果 PATH 环境变量条目非常多(虽然实际场景中一般在 100 条以内),React 渲染完整列表可能会有微小延迟。当前规模下无影响,但若未来考虑显示大量工具链路径扫描结果,可引入虚拟列表(Virtual List)。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
PathEditor v5.0 的代码库是一个优秀的 Rust + Tauri + React 实践范例。它具有清晰的三层架构、严格的类型和边界检查、以及良好的错误处理机制,整体架构稳健且易于长期维护。
|
||||||
@@ -9,10 +9,22 @@ pub fn load_user_paths() -> Result<Vec<String>, String> {
|
|||||||
registry::load_user_paths()
|
registry::load_user_paths()
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_system_paths(paths: Vec<String>, original: Option<Vec<String>>) -> Result<(), String> {
|
||||||
|
if let Some(orig) = original {
|
||||||
|
let current = registry::load_system_paths()?;
|
||||||
|
if current != orig {
|
||||||
|
return Err("注册表已被其他进程修改,请重新加载后重试".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
registry::save_system_paths(paths)
|
registry::save_system_paths(paths)
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_user_paths(paths: Vec<String>, original: Option<Vec<String>>) -> Result<(), String> {
|
||||||
|
if let Some(orig) = original {
|
||||||
|
let current = registry::load_user_paths()?;
|
||||||
|
if current != orig {
|
||||||
|
return Err("注册表已被其他进程修改,请重新加载后重试".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
registry::save_user_paths(paths)
|
registry::save_user_paths(paths)
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+30
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "patheditor",
|
"name": "patheditor",
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "patheditor",
|
"name": "patheditor",
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.26",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.2.0",
|
||||||
@@ -1346,6 +1347,33 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.26",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
|
||||||
|
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.16.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
|
||||||
|
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.26",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.2.0",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { PathEntry } from '@/core/path-entry';
|
import type { PathEntry } from '@/core/path-entry';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
export function MergePreview() {
|
export function MergePreview() {
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
@@ -33,47 +34,62 @@ export function MergePreview() {
|
|||||||
return merged.filter((r) => r.path.toLowerCase().includes(q));
|
return merged.filter((r) => r.path.toLowerCase().includes(q));
|
||||||
}, [sysPaths, userPaths, searchQuery, t]);
|
}, [sysPaths, userPaths, searchQuery, t]);
|
||||||
|
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: allPaths.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 28, // 预估行高 28px
|
||||||
|
initialRect: { width: 800, height: 600 },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div ref={parentRef} className="flex-1 overflow-auto relative">
|
||||||
<table className="w-full border-collapse">
|
<div
|
||||||
<thead>
|
className="sticky top-0 z-10 flex text-left text-xs uppercase"
|
||||||
<tr
|
|
||||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
|
||||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
<th className="w-10 px-2 py-1">#</th>
|
<div className="w-10 px-2 py-1">#</div>
|
||||||
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
|
<div className="px-2 py-1 flex-1">{t('dialog.pathLabel')}</div>
|
||||||
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
|
<div className="w-16 px-2 py-1">{t('merge.source')}</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div
|
||||||
<tbody>
|
style={{
|
||||||
{allPaths.map(({ path, enabled, source, displayIndex }, rowIdx) => {
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const rowIdx = virtualRow.index;
|
||||||
|
const { path, enabled, source, displayIndex } = allPaths[rowIdx];
|
||||||
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
|
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
|
||||||
const textDecoration = enabled ? 'none' : 'line-through';
|
const textDecoration = enabled ? 'none' : 'line-through';
|
||||||
const opacity = enabled ? 1 : 0.6;
|
const opacity = enabled ? 1 : 0.6;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
key={`${source}-${displayIndex}`}
|
key={`${source}-${displayIndex}`}
|
||||||
|
className="flex items-center absolute top-0 left-0 w-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
height: `${virtualRow.size}px`,
|
||||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
backgroundColor: rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||||
color: 'var(--app-fg)',
|
color: 'var(--app-fg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
|
<div className="w-10 px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</div>
|
||||||
<td
|
<div
|
||||||
className="px-2 py-0.5 text-sm"
|
className="px-2 py-0.5 text-sm flex-1 truncate"
|
||||||
style={{ color: textColor, textDecoration, opacity }}
|
style={{ color: textColor, textDecoration, opacity }}
|
||||||
>
|
>
|
||||||
{path}
|
{path}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
|
<div className="w-16 px-2 py-0.5 text-xs opacity-60">{source}</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { TargetType } from '@/core/undo-redo';
|
import { TargetType } from '@/core/undo-redo';
|
||||||
import { usePathValidation } from '@/hooks/use-path-validation';
|
import { usePathValidation } from '@/hooks/use-path-validation';
|
||||||
import type { ValidationState } from '@/hooks/use-path-validation';
|
import type { ValidationState } from '@/hooks/use-path-validation';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
interface PathTableProps {
|
interface PathTableProps {
|
||||||
tabId: 'system' | 'user';
|
tabId: 'system' | 'user';
|
||||||
@@ -80,21 +81,35 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
[isActive, paths],
|
[isActive, paths],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: filtered.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 28, // 预估行高 28px
|
||||||
|
initialRect: { width: 800, height: 600 },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div ref={parentRef} className="flex-1 overflow-auto relative">
|
||||||
<table className="w-full border-collapse">
|
<div
|
||||||
<thead>
|
className="sticky top-0 z-10 flex text-left text-xs uppercase"
|
||||||
<tr
|
|
||||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
|
||||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
<th className="w-8 px-2 py-1">#</th>
|
<div className="w-8 px-2 py-1">#</div>
|
||||||
<th className="w-6 px-1 py-1"></th>
|
<div className="w-6 px-1 py-1"></div>
|
||||||
<th className="px-2 py-1">{t('table.path')}</th>
|
<div className="px-2 py-1 flex-1">{t('table.path')}</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div
|
||||||
<tbody>
|
style={{
|
||||||
{filtered.map(({ path, index, enabled }, rowIdx) => {
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const rowIdx = virtualRow.index;
|
||||||
|
const { path, index, enabled } = filtered[rowIdx];
|
||||||
const v = validations[rowIdx];
|
const v = validations[rowIdx];
|
||||||
const isSelected = selectedIndices.includes(index);
|
const isSelected = selectedIndices.includes(index);
|
||||||
let textColor = 'var(--app-fg)';
|
let textColor = 'var(--app-fg)';
|
||||||
@@ -111,12 +126,14 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
key={index}
|
key={virtualRow.key}
|
||||||
onClick={(e) => handleClick(index, e)}
|
onClick={(e) => handleClick(index, e)}
|
||||||
onDoubleClick={() => handleDoubleClick(index)}
|
onDoubleClick={() => handleDoubleClick(index)}
|
||||||
className="cursor-pointer select-none"
|
className="cursor-pointer select-none flex items-center absolute top-0 left-0 w-full"
|
||||||
style={{
|
style={{
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? 'var(--app-select-row)'
|
? 'var(--app-select-row)'
|
||||||
: rowIdx % 2 === 0
|
: rowIdx % 2 === 0
|
||||||
@@ -124,10 +141,10 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
: 'var(--app-list-alt)',
|
: 'var(--app-list-alt)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
<div className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</div>
|
||||||
<td className="w-6 px-1 py-0.5">
|
<div className="w-6 px-1 py-0.5 flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -137,19 +154,18 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td
|
<div
|
||||||
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
className="px-2 py-0.5 text-sm truncate flex-1"
|
||||||
style={{ color: textColor, textDecoration, opacity }}
|
style={{ color: textColor, textDecoration, opacity }}
|
||||||
title={expandedCache.get(path) || undefined}
|
title={expandedCache.get(path) || undefined}
|
||||||
>
|
>
|
||||||
{path}
|
{path}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,9 +352,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
await invoke('backup_registry', { customDir: null })
|
await invoke('backup_registry', { customDir: null })
|
||||||
.catch(() => { backupFailed = true; });
|
.catch(() => { backupFailed = true; });
|
||||||
|
|
||||||
|
const origSys = state._savedSys.filter(e => e.enabled).map(e => e.path);
|
||||||
|
const origUser = state._savedUser.filter(e => e.enabled).map(e => e.path);
|
||||||
|
|
||||||
const [sysResult, userResult] = await Promise.allSettled([
|
const [sysResult, userResult] = await Promise.allSettled([
|
||||||
invoke('save_system_paths', { paths: sysPaths }),
|
invoke('save_system_paths', { paths: sysPaths, original: origSys }),
|
||||||
invoke('save_user_paths', { paths: userPaths }),
|
invoke('save_user_paths', { paths: userPaths, original: origUser }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sysOk = sysResult.status === 'fulfilled';
|
const sysOk = sysResult.status === 'fulfilled';
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ vi.mock('@/store/app-store', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tanstack/react-virtual', () => ({
|
||||||
|
useVirtualizer: (options: any) => ({
|
||||||
|
getVirtualItems: () => {
|
||||||
|
// return an array of objects to mock virtual items
|
||||||
|
return Array.from({ length: options.count }).map((_, index) => ({
|
||||||
|
index,
|
||||||
|
start: index * 28,
|
||||||
|
size: 28,
|
||||||
|
key: `mock-key-${index}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
getTotalSize: () => options.count * 28,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/i18n', () => ({
|
vi.mock('@/i18n', () => ({
|
||||||
default: { t: vi.fn((key: string) => key) },
|
default: { t: vi.fn((key: string) => key) },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -47,12 +47,25 @@ describe('pathClean', () => {
|
|||||||
expect(removed.length).toBe(1);
|
expect(removed.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('保留第一个出现的 enabled 状态', () => {
|
||||||
|
const [kept, removed] = pathClean([pe('C:\\Valid', false), pe('C:\\Valid', true)], alwaysValid);
|
||||||
|
expect(kept.length).toBe(1);
|
||||||
|
expect(kept[0].enabled).toBe(false); // 第一个状态
|
||||||
|
expect(removed.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('全部有效无变化', () => {
|
it('全部有效无变化', () => {
|
||||||
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
|
const [kept, removed] = pathClean([pe('C:\\a'), pe('D:\\b')], alwaysValid);
|
||||||
expect(kept.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
|
expect(kept.map(e => e.path)).toEqual(['C:\\a', 'D:\\b']);
|
||||||
expect(removed.length).toBe(0);
|
expect(removed.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('空数组处理', () => {
|
||||||
|
const [kept, removed] = pathClean([], alwaysValid);
|
||||||
|
expect(kept.length).toBe(0);
|
||||||
|
expect(removed.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('全部无效全部移除', () => {
|
it('全部无效全部移除', () => {
|
||||||
const [kept, removed] = pathClean([pe('C:\\Invalid1'), pe('C:\\Invalid2')], validateFn);
|
const [kept, removed] = pathClean([pe('C:\\Invalid1'), pe('C:\\Invalid2')], validateFn);
|
||||||
expect(kept.length).toBe(0);
|
expect(kept.length).toBe(0);
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ describe('UndoRedoManager', () => {
|
|||||||
expect(mgr.canRedo()).toBe(false);
|
expect(mgr.canRedo()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('空历史栈的撤销与重做', () => {
|
||||||
|
expect(mgr.undo(sys, user)).toBeNull();
|
||||||
|
expect(mgr.redo(sys, user)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超出栈底/栈顶的安全处理', () => {
|
||||||
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')]));
|
||||||
|
sys.push(pe('C:\\NewPath'));
|
||||||
|
|
||||||
|
// undo一次
|
||||||
|
mgr.undo(sys, user);
|
||||||
|
// 再次undo,此时应到达底部返回null
|
||||||
|
expect(mgr.undo(sys, user)).toBeNull();
|
||||||
|
|
||||||
|
// redo一次
|
||||||
|
mgr.redo(sys, user);
|
||||||
|
// 再次redo,应到达顶部返回null
|
||||||
|
expect(mgr.redo(sys, user)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('超出最大历史容量时移除最旧记录', () => {
|
it('超出最大历史容量时移除最旧记录', () => {
|
||||||
const small = new UndoRedoManager(3);
|
const small = new UndoRedoManager(3);
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user