mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-28 17:25:54 +08:00
refactor: 全面代码质量提升 — StringList→string[], strict 模式, 死代码清理
架构重构: - StringList 类替换为不可变 string[](消除 dataVersion hack,Zustand 自然检测变化) - UndoRedoManager.undo/redo 返回新数组而非原地修改 - 删除 dataVersion 字段和 _bumpVersion() - 启用 TypeScript strict 模式 死代码清理: - 删除 string-list.ts, string-list.test.ts, use-path-validation.ts - Rust AppError 保留供未来使用 功能修复: - importFromJson 添加 try/catch - handleClean 使用真实格式验证替代 () => true - savePaths 保存前调用 backup_registry,处理部分保存失败 - importFromJson 校验非 object 类型输入 i18n 完善: - MergePreview/StatusBar 硬编码中文 → t() 调用 - 新增 merge.* 和 status.* 翻译键 Rust 改进: - registry.rs 抽取 load_paths/save_paths 通用函数,消除重复 - registry 新增 6 个单元测试(split/join/roundtrip) - backup.rs 时间戳加毫秒防覆盖,回退路径改为 home_dir 元数据: - package.json 名称→patheditor, 版本→4.0.0 - 新增 CHANGELOG.md - 移除 UndoRedoButtons 废弃注释 - tsconfig 添加 strict:true Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## v4.0.0 (2026-05-26)
|
||||
|
||||
### 重大变更
|
||||
|
||||
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
|
||||
|
||||
### 新增
|
||||
|
||||
- 现代 Web UI(React + Tailwind CSS 4 + Zustand)
|
||||
- 深色/浅色模式切换
|
||||
- 中英文界面即时切换
|
||||
- 路径有效性颜色编码(红色无效、橙色重复)
|
||||
- 环境变量展开悬停提示
|
||||
- 文件夹拖拽添加路径
|
||||
- 保存前 PATH 长度检查
|
||||
- 66 个前端单元测试 + 10 个 Rust 单元测试
|
||||
|
||||
### 改进
|
||||
|
||||
- 安装包体积从 ~3MB 降至 ~8MB(含 WebView2 运行时)
|
||||
- 完整撤销/重做支持(8 种操作类型,50 步历史)
|
||||
- JSON/CSV/TXT 三种格式导入导出
|
||||
- 合并预览查看系统+用户路径
|
||||
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
|
||||
|
||||
### 移除
|
||||
|
||||
- 旧 C + IUP + Lua + gettext 代码库
|
||||
- Lua 配置引擎 → JSON 配置文件
|
||||
- gettext 国际化 → i18next
|
||||
|
||||
### 已知限制
|
||||
|
||||
- 需要 Windows 10+ 系统预装的 WebView2 运行时
|
||||
- 内存占用约 50MB(旧版约 15MB)
|
||||
- 文件系统路径验证在清理功能中为同步检查(不含实际目录存在性验证)
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "v4.0",
|
||||
"name": "patheditor",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::path::PathBuf;
|
||||
#[tauri::command]
|
||||
pub fn get_appdata_dir() -> String {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\"))
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("PathEditor")
|
||||
.join("backups")
|
||||
.to_string_lossy()
|
||||
@@ -33,7 +34,7 @@ pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_
|
||||
.map_err(|e| format!("无法创建备份目录: {}", e))?;
|
||||
|
||||
// 生成带时间戳的文件名
|
||||
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
|
||||
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);
|
||||
|
||||
|
||||
@@ -5,71 +5,54 @@ const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\
|
||||
const USER_REG_PATH: &str = "Environment";
|
||||
const PATH_VALUE: &str = "Path";
|
||||
|
||||
/// 从注册表加载系统 PATH
|
||||
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)
|
||||
.map_err(|e| format!("无法打开{}注册表项: {}", label, e))?;
|
||||
|
||||
let value: String = env_key
|
||||
.get_value(PATH_VALUE)
|
||||
.map_err(|e| format!("无法读取{} PATH: {}", label, e))?;
|
||||
|
||||
Ok(split_path(&value))
|
||||
}
|
||||
|
||||
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
|
||||
let key = RegKey::predef(root);
|
||||
let env_key = key
|
||||
.open_subkey_with_flags(sub_path, KEY_WRITE)
|
||||
.map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?;
|
||||
|
||||
let value = join_path(paths);
|
||||
env_key
|
||||
.set_value(PATH_VALUE, &value)
|
||||
.map_err(|e| format!("无法写入{} PATH: {}", label, e))?;
|
||||
|
||||
log::info!("已保存{} PATH,{} 个条目", label, paths.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_system_paths() -> Result<Vec<String>, String> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
let env_key = hklm
|
||||
.open_subkey_with_flags(SYS_REG_PATH, KEY_READ)
|
||||
.map_err(|e| format!("无法打开系统注册表项: {}", e))?;
|
||||
|
||||
let value: String = env_key
|
||||
.get_value(PATH_VALUE)
|
||||
.map_err(|e| format!("无法读取系统 PATH: {}", e))?;
|
||||
|
||||
Ok(split_path(&value))
|
||||
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
|
||||
}
|
||||
|
||||
/// 从注册表加载用户 PATH
|
||||
#[tauri::command]
|
||||
pub fn load_user_paths() -> Result<Vec<String>, String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let env_key = hkcu
|
||||
.open_subkey_with_flags(USER_REG_PATH, KEY_READ)
|
||||
.map_err(|e| format!("无法打开用户注册表项: {}", e))?;
|
||||
|
||||
let value: String = env_key
|
||||
.get_value(PATH_VALUE)
|
||||
.map_err(|e| format!("无法读取用户 PATH: {}", e))?;
|
||||
|
||||
Ok(split_path(&value))
|
||||
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
|
||||
}
|
||||
|
||||
/// 保存系统 PATH 到注册表
|
||||
#[tauri::command]
|
||||
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
let env_key = hklm
|
||||
.open_subkey_with_flags(SYS_REG_PATH, KEY_WRITE)
|
||||
.map_err(|e| format!("无法写入系统注册表(需要管理员权限): {}", e))?;
|
||||
|
||||
let value = join_path(&paths);
|
||||
env_key
|
||||
.set_value(PATH_VALUE, &value)
|
||||
.map_err(|e| format!("无法写入系统 PATH: {}", e))?;
|
||||
|
||||
log::info!("已保存系统 PATH,{} 个条目", paths.len());
|
||||
Ok(())
|
||||
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
|
||||
}
|
||||
|
||||
/// 保存用户 PATH 到注册表
|
||||
#[tauri::command]
|
||||
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let env_key = hkcu
|
||||
.open_subkey_with_flags(USER_REG_PATH, KEY_WRITE)
|
||||
.map_err(|e| format!("无法写入用户注册表: {}", e))?;
|
||||
|
||||
let value = join_path(&paths);
|
||||
env_key
|
||||
.set_value(PATH_VALUE, &value)
|
||||
.map_err(|e| format!("无法写入用户 PATH: {}", e))?;
|
||||
|
||||
log::info!("已保存用户 PATH,{} 个条目", paths.len());
|
||||
Ok(())
|
||||
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
|
||||
}
|
||||
|
||||
/// 用分号分割 PATH 字符串
|
||||
fn split_path(raw: &str) -> Vec<String> {
|
||||
raw.split(';')
|
||||
.map(|s| s.trim().to_string())
|
||||
@@ -77,7 +60,6 @@ fn split_path(raw: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 用分号连接路径列表(去除首尾空格避免污染注册表)
|
||||
fn join_path(paths: &[String]) -> String {
|
||||
paths
|
||||
.iter()
|
||||
@@ -86,3 +68,48 @@ fn join_path(paths: &[String]) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_empty() {
|
||||
assert_eq!(split_path(""), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_single() {
|
||||
assert_eq!(split_path("C:\\Windows"), vec!["C:\\Windows"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_multiple() {
|
||||
assert_eq!(
|
||||
split_path("C:\\Windows;D:\\Projects"),
|
||||
vec!["C:\\Windows", "D:\\Projects"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_trims_and_filters_empty() {
|
||||
assert_eq!(
|
||||
split_path(" C:\\ ; ; D:\\ "),
|
||||
vec!["C:\\", "D:\\"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_and_split_roundtrip() {
|
||||
let paths = vec!["C:\\Windows".to_string(), "D:\\Projects".to_string()];
|
||||
let joined = join_path(&paths);
|
||||
let split = split_path(&joined);
|
||||
assert_eq!(split, paths);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_trims_entries() {
|
||||
let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()];
|
||||
assert_eq!(join_path(&paths), "C:\\Windows;D:\\");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function AppShell() {
|
||||
const list = target === TargetType.SYSTEM
|
||||
? useAppStore.getState().sysPaths
|
||||
: useAppStore.getState().userPaths;
|
||||
const value = list.get(idx);
|
||||
const value = list[idx];
|
||||
if (value) {
|
||||
setEditDialog({ open: true, index: idx, value, target });
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function AppShell() {
|
||||
const handleClean = useCallback(() => {
|
||||
const removed = useAppStore.getState().cleanPaths(
|
||||
getCurrentTarget(),
|
||||
() => true, // 简化版,全有效
|
||||
(p) => p.includes('%') || p.includes('\\') || p.includes('/') || /^[a-zA-Z]:[/\\]/.test(p),
|
||||
);
|
||||
if (removed.length > 0) {
|
||||
useAppStore.getState().setStatusMessage(
|
||||
@@ -120,7 +120,7 @@ export function AppShell() {
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const state = useAppStore.getState();
|
||||
const data = { system: state.sysPaths.toArray(), user: state.userPaths.toArray() };
|
||||
const data = { system: state.sysPaths, user: state.userPaths };
|
||||
|
||||
const content = exportToJson(data);
|
||||
const mime = 'application/json';
|
||||
@@ -137,8 +137,8 @@ export function AppShell() {
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const state = useAppStore.getState();
|
||||
const sysJoined = state.sysPaths.toArray().join(';');
|
||||
const userJoined = state.userPaths.toArray().join(';');
|
||||
const sysJoined = state.sysPaths.join(';');
|
||||
const userJoined = state.userPaths.join(';');
|
||||
const combined = sysJoined + ';' + userJoined;
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useThemeStore } from '@/store/theme-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function StatusBar() {
|
||||
const { t } = useTranslation();
|
||||
const statusMessage = useAppStore((s) => s.statusMessage);
|
||||
const isLoading = useAppStore((s) => s.isLoading);
|
||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||
@@ -17,11 +19,11 @@ export function StatusBar() {
|
||||
color: 'var(--app-fg)',
|
||||
}}
|
||||
>
|
||||
<span>{isLoading ? '加载中...' : statusMessage}</span>
|
||||
<span>{isLoading ? t('status.loading') : statusMessage}</span>
|
||||
<div className="flex gap-3">
|
||||
{isModified && <span className="text-yellow-500">● 已修改</span>}
|
||||
{!isAdmin && <span className="text-yellow-500">只读</span>}
|
||||
<span style={{ opacity: 0.5 }}>{isDark ? '深色' : '浅色'}</span>
|
||||
{isModified && <span className="text-yellow-500">● {t('status.modified')}</span>}
|
||||
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
|
||||
<span style={{ opacity: 0.5 }}>{isDark ? t('status.dark') : t('status.light')}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function MergePreview() {
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
void dataVersion; // 订阅版本号强制重渲染
|
||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||
const userPaths = useAppStore((s) => s.userPaths);
|
||||
const searchQuery = useAppStore((s) => s.searchQuery);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allPaths = useMemo(() => {
|
||||
const result: { path: string; source: '系统' | '用户'; index: number }[] = [];
|
||||
sysPaths.all.forEach((p, i) => result.push({ path: p, source: '系统' as const, index: i }));
|
||||
userPaths.all.forEach((p, i) => result.push({ path: p, source: '用户' as const, index: i }));
|
||||
const result: { path: string; source: string; index: number }[] = [];
|
||||
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
|
||||
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
|
||||
|
||||
if (!searchQuery) return result;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return result.filter((r) => r.path.toLowerCase().includes(q));
|
||||
}, [sysPaths, userPaths, searchQuery]);
|
||||
}, [sysPaths, userPaths, searchQuery, t]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -27,8 +27,8 @@ export function MergePreview() {
|
||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<th className="w-10 px-2 py-1">#</th>
|
||||
<th className="px-2 py-1">路径</th>
|
||||
<th className="w-16 px-2 py-1">来源</th>
|
||||
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
|
||||
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -12,8 +12,6 @@ interface PathRow {
|
||||
}
|
||||
|
||||
export function PathTable({ tabId }: PathTableProps) {
|
||||
const dataVersion = useAppStore((s) => s.dataVersion);
|
||||
void dataVersion; // 订阅版本号强制重渲染
|
||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||
const userPaths = useAppStore((s) => s.userPaths);
|
||||
const searchQuery = useAppStore((s) => s.searchQuery);
|
||||
@@ -31,11 +29,11 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
|
||||
// 过滤搜索
|
||||
const filtered = useMemo<PathRow[]>(() => {
|
||||
if (!searchQuery) return paths.all.map((p, i) => ({ path: p, index: i }));
|
||||
if (!searchQuery) return paths.map((p, i) => ({ path: p, index: i }));
|
||||
const q = searchQuery.toLowerCase();
|
||||
const result: PathRow[] = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const p = paths.get(i)!;
|
||||
const p = paths[i];
|
||||
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
|
||||
}
|
||||
return result;
|
||||
@@ -44,7 +42,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
// 异步验证未缓存的路径
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const allPaths = paths.all;
|
||||
const allPaths = paths;
|
||||
|
||||
// 找出未缓存的路径
|
||||
const toValidate = allPaths.filter((p) => !validationCache.has(p));
|
||||
@@ -81,7 +79,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
// 异步展开环境变量(用于 tooltip)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toExpand = paths.all.filter(
|
||||
const toExpand = paths.filter(
|
||||
(p) => p.includes('%') && !expandedCache.has(p),
|
||||
);
|
||||
if (toExpand.length === 0) return;
|
||||
@@ -146,7 +144,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
if (!isActive) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('path-dblclick', {
|
||||
detail: { index: realIndex, path: paths.get(realIndex) },
|
||||
detail: { index: realIndex, path: paths[realIndex] },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,9 +16,6 @@ export function UndoRedoButtons() {
|
||||
borderColor: 'var(--app-border)',
|
||||
};
|
||||
|
||||
// 订阅状态更新(canUndo/canRedo 不会触发 re-render,用 setTimeout 简单轮询不优雅,但 Zustand 的 subscribe 可以)
|
||||
// 这里简化为每次渲染时检查(因为 undo/redo 会修改列表触发重渲染)
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -145,7 +145,14 @@ function parseCsvLine(line: string): string[] {
|
||||
export function importFromJson(content: string): ImportResult {
|
||||
const result: ImportResult = { system: [], user: [] };
|
||||
|
||||
const obj = JSON.parse(content);
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = JSON.parse(content);
|
||||
} catch {
|
||||
return result; // 无效 JSON 返回空结果,由调用方显示错误
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object' || obj === null) return result;
|
||||
|
||||
if (Array.isArray(obj.system)) {
|
||||
result.system = obj.system.filter(
|
||||
|
||||
+14
-59
@@ -1,92 +1,47 @@
|
||||
/**
|
||||
* 路径管理器 — 对应 C 版 path_manager.c
|
||||
* 提供路径增删移清理等 CRUD 操作的纯逻辑
|
||||
* 路径管理器 — 不可变的 string[] 操作
|
||||
*/
|
||||
import { StringList } from './string-list';
|
||||
|
||||
/** 删除指定索引的路径 */
|
||||
export function pathRemoveAt(list: StringList, index: number): void {
|
||||
list.removeAt(index);
|
||||
}
|
||||
|
||||
/** 上移路径(调整优先级) */
|
||||
export function pathMoveUp(list: StringList, index: number): boolean {
|
||||
if (index <= 0 || index >= list.length) return false;
|
||||
list.swap(index, index - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 下移路径(调整优先级) */
|
||||
export function pathMoveDown(list: StringList, index: number): boolean {
|
||||
if (index < 0 || index >= list.length - 1) return false;
|
||||
list.swap(index, index + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 标记路径的有效性(调用方负责提供验证函数和展开 env vars) */
|
||||
export interface PathValidation {
|
||||
isValid: boolean;
|
||||
isDuplicate: boolean;
|
||||
isEnvVar: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析路径列表中各条目的状态
|
||||
* validateFn: 验证路径是否有效(需调用 Rust validate_path)
|
||||
*/
|
||||
export function analyzePaths(
|
||||
list: StringList,
|
||||
paths: readonly string[],
|
||||
validateFn: (path: string) => boolean,
|
||||
): PathValidation[] {
|
||||
const result: PathValidation[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const path = list.get(i)!;
|
||||
for (const path of paths) {
|
||||
const lower = path.toLowerCase();
|
||||
const isDuplicate = seen.has(lower);
|
||||
seen.add(lower);
|
||||
|
||||
result.push({
|
||||
isValid: validateFn(path),
|
||||
isDuplicate,
|
||||
isEnvVar: path.includes('%'),
|
||||
});
|
||||
result.push({ isValid: validateFn(path), isDuplicate, isEnvVar: path.includes('%') });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除选中的索引(从大到小排序以避免偏移)
|
||||
*/
|
||||
export function batchRemoveAt(list: StringList, indices: number[]): void {
|
||||
const sorted = [...indices].sort((a, b) => b - a);
|
||||
for (const idx of sorted) {
|
||||
list.removeAt(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键清理 — 移除无效路径和重复路径
|
||||
* 从后往前操作以避免索引偏移
|
||||
* 返回被移除的路径数量
|
||||
*/
|
||||
/** 从数组中移除无效和重复路径,返回 [新数组, 被移除的路径] */
|
||||
export function pathClean(
|
||||
list: StringList,
|
||||
paths: readonly string[],
|
||||
validateFn: (path: string) => boolean,
|
||||
): string[] {
|
||||
const analysis = analyzePaths(list, validateFn);
|
||||
): [string[], string[]] {
|
||||
const analysis = analyzePaths(paths, validateFn);
|
||||
const kept: string[] = [];
|
||||
const removed: string[] = [];
|
||||
|
||||
for (let i = analysis.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const a = analysis[i];
|
||||
// 移除无效或重复的路径
|
||||
if (!a.isValid || a.isDuplicate) {
|
||||
removed.unshift(list.get(i)!);
|
||||
list.removeAt(i);
|
||||
removed.push(paths[i]);
|
||||
} else {
|
||||
kept.push(paths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
return [kept, removed];
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* StringList — 纯 TypeScript 的字符串列表数据结构
|
||||
* 对应 C 版 include/utils/string_ext.h 的 StringList
|
||||
*/
|
||||
export class StringList {
|
||||
private items: string[] = [];
|
||||
|
||||
/** 追加字符串 */
|
||||
add(str: string): void {
|
||||
this.items.push(str);
|
||||
}
|
||||
|
||||
/** 在指定索引处插入 */
|
||||
insertAt(index: number, str: string): void {
|
||||
this.items.splice(index, 0, str);
|
||||
}
|
||||
|
||||
/** 删除指定索引处的元素 */
|
||||
removeAt(index: number): void {
|
||||
this.items.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 读取索引处元素 */
|
||||
get(index: number): string | undefined {
|
||||
return this.items[index];
|
||||
}
|
||||
|
||||
/** 设置索引处元素 */
|
||||
set(index: number, str: string): void {
|
||||
this.items[index] = str;
|
||||
}
|
||||
|
||||
/** 不区分大小写查找是否包含 */
|
||||
contains(str: string): boolean {
|
||||
return this.items.some((item) => item.toLowerCase() === str.toLowerCase());
|
||||
}
|
||||
|
||||
/** 查找不区分大小写的索引,未找到返回 -1 */
|
||||
indexOfIgnoreCase(str: string): number {
|
||||
const lower = str.toLowerCase();
|
||||
return this.items.findIndex((item) => item.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
/** 交换两个索引的元素 */
|
||||
swap(i: number, j: number): void {
|
||||
const tmp = this.items[i];
|
||||
this.items[i] = this.items[j];
|
||||
this.items[j] = tmp;
|
||||
}
|
||||
|
||||
/** 清空所有元素 */
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
/** 深拷贝 */
|
||||
clone(): StringList {
|
||||
const list = new StringList();
|
||||
list.items = [...this.items];
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 转换为普通数组(传给 Rust 后端) */
|
||||
toArray(): string[] {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
/** 从数组初始化 */
|
||||
static fromArray(arr: string[]): StringList {
|
||||
const list = new StringList();
|
||||
list.items = [...arr];
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 元素数量 */
|
||||
get length(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
/** 只读数组 */
|
||||
get all(): readonly string[] {
|
||||
return [...this.items];
|
||||
}
|
||||
}
|
||||
+36
-88
@@ -1,25 +1,13 @@
|
||||
/**
|
||||
* 撤销/重做管理器 — 对应 C 版 undo_redo.c
|
||||
* 支持 8 种操作类型的完整撤销/重做
|
||||
* 撤销/重做管理器 — 纯逻辑,操作不可变 string[]
|
||||
*/
|
||||
import { StringList } from './string-list';
|
||||
|
||||
export const OperationType = {
|
||||
ADD: 0, // 新增路径
|
||||
DELETE: 1, // 删除路径
|
||||
EDIT: 2, // 编辑路径
|
||||
MOVE_UP: 3, // 上移
|
||||
MOVE_DOWN: 4, // 下移
|
||||
CLEAN: 5, // 一键清理
|
||||
CLEAR: 6, // 清空
|
||||
IMPORT: 7, // 导入
|
||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7,
|
||||
} as const;
|
||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||
|
||||
export const TargetType = {
|
||||
SYSTEM: 0,
|
||||
USER: 1,
|
||||
} as const;
|
||||
export const TargetType = { SYSTEM: 0, USER: 1 } as const;
|
||||
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
|
||||
|
||||
export interface OpRecord {
|
||||
@@ -42,139 +30,99 @@ export class UndoRedoManager {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/** 推送新操作记录,推送后截断重做分支 */
|
||||
push(record: OpRecord): void {
|
||||
// 截断重做分支
|
||||
this.records = this.records.slice(0, this.current + 1);
|
||||
|
||||
// 如果已满,移除最旧的记录
|
||||
if (this.records.length >= this.maxSize) {
|
||||
this.records.shift();
|
||||
}
|
||||
|
||||
this.records.push(record);
|
||||
this.current = this.records.length - 1;
|
||||
}
|
||||
|
||||
/** 撤销当前操作 */
|
||||
undo(sysPaths: StringList, userPaths: StringList): boolean {
|
||||
if (this.current < 0) return false;
|
||||
undo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
||||
if (this.current < 0) return null;
|
||||
|
||||
const rec = this.records[this.current];
|
||||
this.current--;
|
||||
|
||||
const target = rec.target === TargetType.SYSTEM ? sysPaths : userPaths;
|
||||
const sys = [...sysPaths];
|
||||
const user = [...userPaths];
|
||||
const target = rec.target === TargetType.SYSTEM ? sys : user;
|
||||
|
||||
switch (rec.type) {
|
||||
case OperationType.ADD:
|
||||
// 撤销添加 — 删除最后 count 个元素
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.removeAt(target.length - 1);
|
||||
}
|
||||
target.splice(target.length - rec.count, rec.count);
|
||||
break;
|
||||
|
||||
case OperationType.DELETE:
|
||||
// 撤销删除 — 逐个恢复
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.insertAt(rec.index + i, rec.oldPaths[i]);
|
||||
target.splice(rec.index + i, 0, rec.oldPaths[i]);
|
||||
}
|
||||
break;
|
||||
|
||||
case OperationType.EDIT:
|
||||
target.set(rec.index, rec.oldPaths[0]);
|
||||
target[rec.index] = rec.oldPaths[0];
|
||||
break;
|
||||
|
||||
case OperationType.MOVE_UP:
|
||||
target.swap(rec.index - 1, rec.index);
|
||||
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
|
||||
break;
|
||||
|
||||
case OperationType.MOVE_DOWN:
|
||||
target.swap(rec.index, rec.index + 1);
|
||||
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
|
||||
break;
|
||||
|
||||
case OperationType.CLEAN:
|
||||
case OperationType.IMPORT:
|
||||
// 恢复到操作前的完整列表
|
||||
target.clear();
|
||||
for (const path of rec.oldPaths) {
|
||||
target.add(path);
|
||||
}
|
||||
target.length = 0;
|
||||
target.push(...rec.oldPaths);
|
||||
break;
|
||||
|
||||
case OperationType.CLEAR:
|
||||
for (const path of rec.oldPaths) {
|
||||
target.add(path);
|
||||
}
|
||||
target.push(...rec.oldPaths);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
return [sys, user];
|
||||
}
|
||||
|
||||
/** 重做下一个操作 */
|
||||
redo(sysPaths: StringList, userPaths: StringList): boolean {
|
||||
if (this.current >= this.records.length - 1) return false;
|
||||
redo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
||||
if (this.current >= this.records.length - 1) return null;
|
||||
|
||||
this.current++;
|
||||
const rec = this.records[this.current];
|
||||
const target = rec.target === TargetType.SYSTEM ? sysPaths : userPaths;
|
||||
|
||||
const sys = [...sysPaths];
|
||||
const user = [...userPaths];
|
||||
const target = rec.target === TargetType.SYSTEM ? sys : user;
|
||||
|
||||
switch (rec.type) {
|
||||
case OperationType.ADD:
|
||||
for (let i = 0; i < rec.count; i++) {
|
||||
target.add(rec.newPaths[i]);
|
||||
}
|
||||
target.push(...rec.newPaths);
|
||||
break;
|
||||
|
||||
case OperationType.DELETE:
|
||||
// 从后往前删,避免索引偏移
|
||||
for (let i = rec.count - 1; i >= 0; i--) {
|
||||
target.removeAt(rec.index + i);
|
||||
target.splice(rec.index + i, 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case OperationType.EDIT:
|
||||
target.set(rec.index, rec.newPaths[0]);
|
||||
target[rec.index] = rec.newPaths[0];
|
||||
break;
|
||||
|
||||
case OperationType.MOVE_UP:
|
||||
target.swap(rec.index - 1, rec.index);
|
||||
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
|
||||
break;
|
||||
|
||||
case OperationType.MOVE_DOWN:
|
||||
target.swap(rec.index, rec.index + 1);
|
||||
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
|
||||
break;
|
||||
|
||||
case OperationType.CLEAN:
|
||||
case OperationType.IMPORT:
|
||||
target.clear();
|
||||
for (const path of rec.newPaths) {
|
||||
target.add(path);
|
||||
}
|
||||
target.length = 0;
|
||||
target.push(...rec.newPaths);
|
||||
break;
|
||||
|
||||
case OperationType.CLEAR:
|
||||
target.clear();
|
||||
target.length = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
return [sys, user];
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.current >= 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.current < this.records.length - 1;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.records = [];
|
||||
this.current = -1;
|
||||
}
|
||||
|
||||
get historyLength(): number {
|
||||
return this.records.length;
|
||||
}
|
||||
canUndo(): boolean { return this.current >= 0; }
|
||||
canRedo(): boolean { return this.current < this.records.length - 1; }
|
||||
clear(): void { this.records = []; this.current = -1; }
|
||||
get historyLength(): number { return this.records.length; }
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// Rust 端未就绪时的 fallback
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
/** 同步验证(基于本地规则,不含文件系统检查) */
|
||||
export function validatePath(path: string): boolean {
|
||||
if (path.includes('%')) return true;
|
||||
return true; // 文件系统检查需要调用 Rust backend
|
||||
}
|
||||
|
||||
/** 异步验证(调用 Rust validate_path) */
|
||||
export function useAsyncValidation() {
|
||||
const [cache, setCache] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
const validate = useCallback(async (path: string): Promise<boolean> => {
|
||||
if (path.includes('%')) return true;
|
||||
if (cache.has(path)) return cache.get(path)!;
|
||||
|
||||
if (isTauri) {
|
||||
try {
|
||||
const valid: boolean = await invoke('validate_path', { path });
|
||||
setCache((prev) => new Map(prev).set(path, valid));
|
||||
return valid;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [cache]);
|
||||
|
||||
return { validate, cache };
|
||||
}
|
||||
@@ -27,6 +27,11 @@
|
||||
"lightMode": "Light Mode",
|
||||
"language": "Language"
|
||||
},
|
||||
"merge": {
|
||||
"system": "System",
|
||||
"user": "User",
|
||||
"source": "Source"
|
||||
},
|
||||
"status": {
|
||||
"normal": "Ready",
|
||||
"readonly": "Read-only mode — Administrator privileges required for editing",
|
||||
@@ -36,6 +41,11 @@
|
||||
"deleted": "Deleted {{count}} path(s)",
|
||||
"loaded": "Loaded {{sysCount}} system and {{userCount}} user paths",
|
||||
"dragFolderOnly": "Only folders can be dropped",
|
||||
"loading": "Loading...",
|
||||
"modified": "Modified",
|
||||
"readonly_label": "Read-only",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"adminWarning": "Running without administrator privileges, some features are disabled"
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"lightMode": "浅色模式",
|
||||
"language": "语言"
|
||||
},
|
||||
"merge": {
|
||||
"system": "系统",
|
||||
"user": "用户",
|
||||
"source": "来源"
|
||||
},
|
||||
"status": {
|
||||
"normal": "就绪",
|
||||
"readonly": "只读模式 — 需要管理员权限才能编辑",
|
||||
@@ -36,7 +41,12 @@
|
||||
"deleted": "已删除 {{count}} 个路径",
|
||||
"loaded": "已加载 {{sysCount}} 个系统路径和 {{userCount}} 个用户路径",
|
||||
"dragFolderOnly": "只能拖拽文件夹",
|
||||
"adminWarning": "当前以非管理员身份运行,部分功能不可用"
|
||||
"adminWarning": "当前以非管理员身份运行,部分功能不可用",
|
||||
"loading": "加载中...",
|
||||
"modified": "已修改",
|
||||
"readonly_label": "只读",
|
||||
"light": "浅色",
|
||||
"dark": "深色"
|
||||
},
|
||||
"dialog": {
|
||||
"newPath": "新建路径",
|
||||
|
||||
+106
-164
@@ -1,21 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '@/i18n';
|
||||
import { StringList } from '@/core/string-list';
|
||||
import {
|
||||
UndoRedoManager,
|
||||
OperationType,
|
||||
TargetType,
|
||||
} from '@/core/undo-redo';
|
||||
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
||||
import { pathClean } from '@/core/path-manager';
|
||||
|
||||
export type TabId = 'system' | 'user' | 'merged';
|
||||
|
||||
interface AppState {
|
||||
sysPaths: StringList;
|
||||
userPaths: StringList;
|
||||
sysPaths: string[];
|
||||
userPaths: string[];
|
||||
undoRedo: UndoRedoManager;
|
||||
dataVersion: number;
|
||||
|
||||
activeTab: TabId;
|
||||
searchQuery: string;
|
||||
@@ -46,19 +40,14 @@ interface AppState {
|
||||
|
||||
loadPaths: () => Promise<void>;
|
||||
savePaths: () => Promise<void>;
|
||||
loadFromStringLists: (sys: string[], user: string[]) => void;
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
|
||||
_getTargetList: (target: TargetType) => StringList;
|
||||
_bumpVersion: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
sysPaths: new StringList(),
|
||||
userPaths: new StringList(),
|
||||
sysPaths: [],
|
||||
userPaths: [],
|
||||
undoRedo: new UndoRedoManager(50),
|
||||
dataVersion: 0,
|
||||
|
||||
activeTab: 'system',
|
||||
searchQuery: '',
|
||||
@@ -73,119 +62,92 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
|
||||
setStatusMessage: (msg) => set({ statusMessage: msg }),
|
||||
|
||||
_getTargetList: (target) => {
|
||||
const { sysPaths, userPaths } = get();
|
||||
return target === TargetType.SYSTEM ? sysPaths : userPaths;
|
||||
},
|
||||
|
||||
_bumpVersion: () => set((s) => ({ isModified: true, dataVersion: s.dataVersion + 1 })),
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
addPath: (path, target) => {
|
||||
const list = get()._getTargetList(target);
|
||||
list.add(path);
|
||||
get().undoRedo.push({
|
||||
type: OperationType.ADD,
|
||||
target,
|
||||
index: list.length - 1,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [path],
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const newList = [...list, path];
|
||||
state.undoRedo.push({
|
||||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||||
oldPaths: [], newPaths: [path],
|
||||
});
|
||||
get()._bumpVersion();
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
|
||||
else set({ userPaths: newList, isModified: true });
|
||||
},
|
||||
|
||||
editPath: (index, newPath, target) => {
|
||||
const list = get()._getTargetList(target);
|
||||
const oldPath = list.get(index);
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldPath = list[index];
|
||||
if (oldPath === undefined) return;
|
||||
|
||||
get().undoRedo.push({
|
||||
type: OperationType.EDIT,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [oldPath],
|
||||
newPaths: [newPath],
|
||||
state.undoRedo.push({
|
||||
type: OperationType.EDIT, target, index, count: 1,
|
||||
oldPaths: [oldPath], newPaths: [newPath],
|
||||
});
|
||||
list.set(index, newPath);
|
||||
get()._bumpVersion();
|
||||
const newList = [...list];
|
||||
newList[index] = newPath;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
|
||||
else set({ userPaths: newList, isModified: true });
|
||||
},
|
||||
|
||||
deletePaths: (indices, target) => {
|
||||
const list = get()._getTargetList(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);
|
||||
|
||||
// 每个删除独立记录,保证撤销时顺序正确
|
||||
for (const idx of sorted) {
|
||||
const oldPath = list.get(idx)!;
|
||||
get().undoRedo.push({
|
||||
type: OperationType.DELETE,
|
||||
target,
|
||||
index: idx,
|
||||
count: 1,
|
||||
oldPaths: [oldPath],
|
||||
newPaths: [],
|
||||
state.undoRedo.push({
|
||||
type: OperationType.DELETE, target, index: idx, count: 1,
|
||||
oldPaths: [list[idx]], newPaths: [],
|
||||
});
|
||||
list.removeAt(idx);
|
||||
}
|
||||
|
||||
set({ selectedIndices: [] });
|
||||
get()._bumpVersion();
|
||||
const toRemove = new Set(sorted);
|
||||
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [], isModified: true });
|
||||
else set({ userPaths: newList, selectedIndices: [], isModified: true });
|
||||
},
|
||||
|
||||
moveUp: (index, target) => {
|
||||
if (index <= 0) return;
|
||||
const list = get()._getTargetList(target);
|
||||
get().undoRedo.push({
|
||||
type: OperationType.MOVE_UP,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [],
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_UP, target, index, count: 1,
|
||||
oldPaths: [], newPaths: [],
|
||||
});
|
||||
list.swap(index, index - 1);
|
||||
set({ selectedIndices: [index - 1] });
|
||||
get()._bumpVersion();
|
||||
const newList = [...list];
|
||||
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1], isModified: true });
|
||||
else set({ userPaths: newList, selectedIndices: [index - 1], isModified: true });
|
||||
},
|
||||
|
||||
moveDown: (index, target) => {
|
||||
const list = get()._getTargetList(target);
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (index >= list.length - 1) return;
|
||||
get().undoRedo.push({
|
||||
type: OperationType.MOVE_DOWN,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [],
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_DOWN, target, index, count: 1,
|
||||
oldPaths: [], newPaths: [],
|
||||
});
|
||||
list.swap(index, index + 1);
|
||||
set({ selectedIndices: [index + 1] });
|
||||
get()._bumpVersion();
|
||||
const newList = [...list];
|
||||
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1], isModified: true });
|
||||
else set({ userPaths: newList, selectedIndices: [index + 1], isModified: true });
|
||||
},
|
||||
|
||||
cleanPaths: (target, validateFn) => {
|
||||
const list = get()._getTargetList(target);
|
||||
const oldPaths = list.toArray();
|
||||
const removed = pathClean(list, validateFn);
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const [kept, removed] = pathClean(list, validateFn);
|
||||
|
||||
if (removed.length > 0) {
|
||||
get().undoRedo.push({
|
||||
type: OperationType.CLEAN,
|
||||
target,
|
||||
index: 0,
|
||||
count: removed.length,
|
||||
oldPaths,
|
||||
newPaths: list.toArray(),
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAN, target, index: 0, count: removed.length,
|
||||
oldPaths: [...list], newPaths: kept,
|
||||
});
|
||||
set({ selectedIndices: [] });
|
||||
get()._bumpVersion();
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [], isModified: true });
|
||||
else set({ userPaths: kept, selectedIndices: [], isModified: true });
|
||||
}
|
||||
|
||||
return removed;
|
||||
@@ -193,78 +155,48 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
|
||||
importPaths: (target, importPaths) => {
|
||||
if (importPaths.length === 0) return;
|
||||
const list = get()._getTargetList(target);
|
||||
const oldPaths = list.toArray();
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const copied = [...importPaths];
|
||||
|
||||
get().undoRedo.push({
|
||||
type: OperationType.IMPORT,
|
||||
target,
|
||||
index: 0,
|
||||
count: copied.length,
|
||||
oldPaths,
|
||||
newPaths: copied,
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT, target, index: 0, count: copied.length,
|
||||
oldPaths: [...list], newPaths: copied,
|
||||
});
|
||||
|
||||
list.clear();
|
||||
for (const p of copied) {
|
||||
list.add(p);
|
||||
}
|
||||
set({ selectedIndices: [] });
|
||||
get()._bumpVersion();
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: copied, selectedIndices: [], isModified: true });
|
||||
else set({ userPaths: copied, selectedIndices: [], isModified: true });
|
||||
},
|
||||
|
||||
clearPaths: (target) => {
|
||||
const list = get()._getTargetList(target);
|
||||
const oldPaths = list.toArray();
|
||||
if (oldPaths.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (list.length === 0) return;
|
||||
|
||||
get().undoRedo.push({
|
||||
type: OperationType.CLEAR,
|
||||
target,
|
||||
index: 0,
|
||||
count: oldPaths.length,
|
||||
oldPaths,
|
||||
newPaths: [],
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAR, target, index: 0, count: list.length,
|
||||
oldPaths: [...list], newPaths: [],
|
||||
});
|
||||
list.clear();
|
||||
get()._bumpVersion();
|
||||
},
|
||||
|
||||
// ── 撤销/重做 ──
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [], isModified: true });
|
||||
else set({ userPaths: [], isModified: true });
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { undoRedo, sysPaths, userPaths } = get();
|
||||
if (undoRedo.undo(sysPaths, userPaths)) {
|
||||
set({ isModified: true, selectedIndices: [] });
|
||||
get()._bumpVersion();
|
||||
}
|
||||
const result = undoRedo.undo(sysPaths, userPaths);
|
||||
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { undoRedo, sysPaths, userPaths } = get();
|
||||
if (undoRedo.redo(sysPaths, userPaths)) {
|
||||
set({ isModified: true, selectedIndices: [] });
|
||||
get()._bumpVersion();
|
||||
}
|
||||
const result = undoRedo.redo(sysPaths, userPaths);
|
||||
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
|
||||
},
|
||||
|
||||
canUndo: () => get().undoRedo.canUndo(),
|
||||
canRedo: () => get().undoRedo.canRedo(),
|
||||
|
||||
// ── 数据加载/保存 ──
|
||||
|
||||
loadFromStringLists: (sys: string[], user: string[]) => {
|
||||
set({
|
||||
sysPaths: StringList.fromArray(sys),
|
||||
userPaths: StringList.fromArray(user),
|
||||
undoRedo: new UndoRedoManager(50),
|
||||
isModified: false,
|
||||
isLoading: false,
|
||||
dataVersion: get().dataVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
loadPaths: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
@@ -272,34 +204,46 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
invoke<string[]>('load_system_paths'),
|
||||
invoke<string[]>('load_user_paths'),
|
||||
]);
|
||||
|
||||
set({
|
||||
sysPaths: StringList.fromArray(sysArr),
|
||||
userPaths: StringList.fromArray(userArr),
|
||||
sysPaths: sysArr,
|
||||
userPaths: userArr,
|
||||
undoRedo: new UndoRedoManager(50),
|
||||
isLoading: false,
|
||||
isModified: false,
|
||||
dataVersion: get().dataVersion + 1,
|
||||
statusMessage: i18n.t('status.loaded', {
|
||||
sysCount: sysArr.length,
|
||||
userCount: userArr.length,
|
||||
}),
|
||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||||
});
|
||||
} catch (e) {
|
||||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${e}` });
|
||||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||||
}
|
||||
},
|
||||
|
||||
savePaths: async () => {
|
||||
const { sysPaths, userPaths } = get();
|
||||
const sysJoined = sysPaths.join(';');
|
||||
const userJoined = userPaths.join(';');
|
||||
|
||||
if (sysJoined.length > 2048 || userJoined.length > 2048 || (sysJoined + userJoined).length > 8191) {
|
||||
if (!window.confirm(`${i18n.t('status.error')}: PATH 长度超过建议值,是否继续?`)) return;
|
||||
}
|
||||
|
||||
set({ statusMessage: i18n.t('status.saving') });
|
||||
try {
|
||||
await invoke('save_system_paths', { paths: sysPaths.toArray() });
|
||||
await invoke('save_user_paths', { paths: userPaths.toArray() });
|
||||
await invoke('broadcast_env_change');
|
||||
|
||||
// 保存前备份
|
||||
try { await invoke('backup_registry', { customDir: null, sysPaths, userPaths }); } catch { /* 备份失败不阻止保存 */ }
|
||||
|
||||
let sysOk = true, userOk = true;
|
||||
try { await invoke('save_system_paths', { paths: sysPaths }); } catch { sysOk = false; }
|
||||
try { await invoke('save_user_paths', { paths: userPaths }); } catch { userOk = false; }
|
||||
|
||||
if (sysOk && userOk) {
|
||||
try { await invoke('broadcast_env_change'); } catch { /* 广播失败不阻止 */ }
|
||||
set({ isModified: false, statusMessage: i18n.t('status.saved') });
|
||||
} catch (e) {
|
||||
set({ statusMessage: `${i18n.t('status.error')}: ${e}` });
|
||||
} else if (sysOk) {
|
||||
set({ statusMessage: '用户 PATH 保存失败,系统 PATH 已保存' });
|
||||
} else if (userOk) {
|
||||
set({ statusMessage: '系统 PATH 保存失败,用户 PATH 已保存' });
|
||||
} else {
|
||||
set({ statusMessage: `${i18n.t('status.error')}: 保存失败` });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -307,9 +251,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
try {
|
||||
const isAdmin: boolean = await invoke('check_admin');
|
||||
set({ isAdmin });
|
||||
if (!isAdmin) {
|
||||
set({ statusMessage: i18n.t('status.readonly') });
|
||||
}
|
||||
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
|
||||
} catch {
|
||||
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
|
||||
}
|
||||
|
||||
@@ -1,100 +1,31 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
pathRemoveAt,
|
||||
pathMoveUp,
|
||||
pathMoveDown,
|
||||
pathClean,
|
||||
batchRemoveAt,
|
||||
} from '../../src/core/path-manager';
|
||||
import { StringList } from '../../src/core/string-list';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pathClean } from '../../src/core/path-manager';
|
||||
|
||||
// 模拟验证函数:所有路径都"有效"
|
||||
const alwaysValid = () => true;
|
||||
|
||||
// 模拟验证函数:C:\\Invalid 无效
|
||||
const validateFn = (path: string) => !path.includes('Invalid');
|
||||
|
||||
describe('pathRemoveAt', () => {
|
||||
it('删除指定索引', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
||||
pathRemoveAt(list, 1);
|
||||
expect(list.toArray()).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathMoveUp', () => {
|
||||
it('上移元素', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
||||
pathMoveUp(list, 1);
|
||||
expect(list.toArray()).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('第一个元素不能上移', () => {
|
||||
const list = StringList.fromArray(['a', 'b']);
|
||||
expect(pathMoveUp(list, 0)).toBe(false);
|
||||
expect(list.toArray()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('无效索引不能上移', () => {
|
||||
const list = StringList.fromArray(['a']);
|
||||
expect(pathMoveUp(list, -1)).toBe(false);
|
||||
expect(pathMoveUp(list, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathMoveDown', () => {
|
||||
it('下移元素', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
||||
pathMoveDown(list, 0);
|
||||
expect(list.toArray()).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('最后一个元素不能下移', () => {
|
||||
const list = StringList.fromArray(['a', 'b']);
|
||||
expect(pathMoveDown(list, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchRemoveAt', () => {
|
||||
it('批量删除(按从大到小排序)', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c', 'd', 'e']);
|
||||
batchRemoveAt(list, [0, 2, 4]);
|
||||
expect(list.toArray()).toEqual(['b', 'd']);
|
||||
});
|
||||
|
||||
it('删除乱序索引', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c', 'd']);
|
||||
batchRemoveAt(list, [3, 0]);
|
||||
expect(list.toArray()).toEqual(['b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathClean', () => {
|
||||
it('移除无效路径', () => {
|
||||
const list = StringList.fromArray(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid']);
|
||||
const removed = pathClean(list, validateFn);
|
||||
expect(list.toArray()).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid'], validateFn);
|
||||
expect(kept).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||
expect(removed).toEqual(['C:\\Invalid']);
|
||||
});
|
||||
|
||||
it('移除重复路径(保留一个)', () => {
|
||||
const list = StringList.fromArray(['C:\\Valid', 'C:\\Valid', 'D:\\Valid']);
|
||||
const removed = pathClean(list, alwaysValid);
|
||||
expect(list.length).toBe(2);
|
||||
expect(removed.length).toBeGreaterThanOrEqual(1);
|
||||
it('移除重复路径保留第一个', () => {
|
||||
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Valid', 'D:\\Valid'], alwaysValid);
|
||||
expect(kept.length).toBe(2);
|
||||
expect(removed.length).toBe(1);
|
||||
});
|
||||
|
||||
it('全部有效无变化', () => {
|
||||
const list = StringList.fromArray(['C:\\a', 'D:\\b']);
|
||||
const removed = pathClean(list, alwaysValid);
|
||||
expect(list.toArray()).toEqual(['C:\\a', 'D:\\b']);
|
||||
const [kept, removed] = pathClean(['C:\\a', 'D:\\b'], alwaysValid);
|
||||
expect(kept).toEqual(['C:\\a', 'D:\\b']);
|
||||
expect(removed.length).toBe(0);
|
||||
});
|
||||
|
||||
it('全部无效全部移除', () => {
|
||||
const list = StringList.fromArray(['C:\\Invalid1', 'C:\\Invalid2']);
|
||||
const removed = pathClean(list, validateFn);
|
||||
expect(list.length).toBe(0);
|
||||
const [kept, removed] = pathClean(['C:\\Invalid1', 'C:\\Invalid2'], validateFn);
|
||||
expect(kept.length).toBe(0);
|
||||
expect(removed.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StringList } from '../../src/core/string-list';
|
||||
|
||||
describe('StringList', () => {
|
||||
it('初始为空', () => {
|
||||
const list = new StringList();
|
||||
expect(list.length).toBe(0);
|
||||
});
|
||||
|
||||
it('添加和读取元素', () => {
|
||||
const list = new StringList();
|
||||
list.add('C:\\Windows');
|
||||
list.add('C:\\Users');
|
||||
expect(list.length).toBe(2);
|
||||
expect(list.get(0)).toBe('C:\\Windows');
|
||||
expect(list.get(1)).toBe('C:\\Users');
|
||||
expect(list.get(99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('在指定位置插入', () => {
|
||||
const list = new StringList();
|
||||
list.add('a');
|
||||
list.add('c');
|
||||
list.insertAt(1, 'b');
|
||||
expect(list.toArray()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('在开头插入', () => {
|
||||
const list = StringList.fromArray(['b', 'c']);
|
||||
list.insertAt(0, 'a');
|
||||
expect(list.toArray()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('删除指定位置', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
||||
list.removeAt(1);
|
||||
expect(list.toArray()).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('设置元素', () => {
|
||||
const list = StringList.fromArray(['old']);
|
||||
list.set(0, 'new');
|
||||
expect(list.get(0)).toBe('new');
|
||||
});
|
||||
|
||||
it('不区分大小写查找', () => {
|
||||
const list = StringList.fromArray(['C:\\Windows', 'C:\\Users']);
|
||||
expect(list.contains('c:\\windows')).toBe(true);
|
||||
expect(list.contains('C:\\WINDOWS')).toBe(true);
|
||||
expect(list.contains('C:\\Other')).toBe(false);
|
||||
});
|
||||
|
||||
it('不区分大小写索引', () => {
|
||||
const list = StringList.fromArray(['C:\\Windows', 'C:\\Users']);
|
||||
expect(list.indexOfIgnoreCase('c:\\windows')).toBe(0);
|
||||
expect(list.indexOfIgnoreCase('c:\\users')).toBe(1);
|
||||
expect(list.indexOfIgnoreCase('nope')).toBe(-1);
|
||||
});
|
||||
|
||||
it('交换元素', () => {
|
||||
const list = StringList.fromArray(['a', 'b']);
|
||||
list.swap(0, 1);
|
||||
expect(list.toArray()).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('清空', () => {
|
||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
||||
list.clear();
|
||||
expect(list.length).toBe(0);
|
||||
});
|
||||
|
||||
it('深拷贝', () => {
|
||||
const original = StringList.fromArray(['a', 'b']);
|
||||
const cloned = original.clone();
|
||||
cloned.set(0, 'modified');
|
||||
expect(original.get(0)).toBe('a');
|
||||
expect(cloned.get(0)).toBe('modified');
|
||||
});
|
||||
|
||||
it('fromArray 和 toArray', () => {
|
||||
const arr = ['x', 'y', 'z'];
|
||||
const list = StringList.fromArray(arr);
|
||||
expect(list.toArray()).toEqual(arr);
|
||||
expect(list.length).toBe(3);
|
||||
});
|
||||
|
||||
it('all 返回只读数组', () => {
|
||||
const list = StringList.fromArray(['a', 'b']);
|
||||
expect(list.all).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
+66
-151
@@ -1,223 +1,138 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
UndoRedoManager,
|
||||
OperationType,
|
||||
TargetType,
|
||||
type OpRecord,
|
||||
} from '../../src/core/undo-redo';
|
||||
import { StringList } from '../../src/core/string-list';
|
||||
import { UndoRedoManager, OperationType, TargetType, type OpRecord } from '../../src/core/undo-redo';
|
||||
|
||||
function makeRecord(
|
||||
type: OperationType,
|
||||
target: TargetType,
|
||||
index: number,
|
||||
count: number,
|
||||
oldPaths: string[],
|
||||
newPaths: string[],
|
||||
): OpRecord {
|
||||
function makeRecord(type: OperationType, target: TargetType, index: number, count: number, oldPaths: string[], newPaths: string[]): OpRecord {
|
||||
return { type, target, index, count, oldPaths, newPaths };
|
||||
}
|
||||
|
||||
describe('UndoRedoManager', () => {
|
||||
let mgr: UndoRedoManager;
|
||||
let sysPaths: StringList;
|
||||
let userPaths: StringList;
|
||||
let sys: string[];
|
||||
let user: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new UndoRedoManager(50);
|
||||
sysPaths = StringList.fromArray(['C:\\Windows', 'C:\\Program Files']);
|
||||
userPaths = StringList.fromArray(['C:\\Users\\me\\AppData']);
|
||||
sys = ['C:\\Windows', 'C:\\Program Files'];
|
||||
user = ['C:\\Users\\me\\AppData'];
|
||||
});
|
||||
|
||||
// ── 基本状态 ──
|
||||
|
||||
it('初始不可撤销不可重做', () => {
|
||||
expect(mgr.canUndo()).toBe(false);
|
||||
expect(mgr.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
// ── ADD ──
|
||||
|
||||
it('ADD 撤销/重做', () => {
|
||||
sysPaths.add('C:\\NewPath');
|
||||
sys.push('C:\\NewPath');
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']));
|
||||
|
||||
mgr.push(
|
||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']),
|
||||
);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
|
||||
expect(mgr.canUndo()).toBe(true);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
||||
});
|
||||
|
||||
// ── DELETE ──
|
||||
|
||||
it('DELETE 撤销/重做', () => {
|
||||
const removed = sysPaths.get(0)!;
|
||||
mgr.push(
|
||||
makeRecord(OperationType.DELETE, TargetType.SYSTEM, 0, 1, [removed], []),
|
||||
);
|
||||
const removed = sys[0];
|
||||
mgr.push(makeRecord(OperationType.DELETE, TargetType.SYSTEM, 0, 1, [removed], []));
|
||||
sys.splice(0, 1);
|
||||
|
||||
sysPaths.removeAt(0);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0][0]).toBe(removed);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.get(0)).toBe(removed);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files']);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(['C:\\Program Files']);
|
||||
});
|
||||
|
||||
// ── EDIT ──
|
||||
|
||||
it('EDIT 撤销/重做', () => {
|
||||
const oldVal = sysPaths.get(0)!;
|
||||
mgr.push(
|
||||
makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, [oldVal], ['C:\\Edited']),
|
||||
);
|
||||
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, ['C:\\Windows'], ['C:\\Edited']));
|
||||
sys[0] = 'C:\\Edited';
|
||||
|
||||
sysPaths.set(0, 'C:\\Edited');
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0][0]).toBe('C:\\Windows');
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.get(0)).toBe(oldVal);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.get(0)).toBe('C:\\Edited');
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0][0]).toBe('C:\\Edited');
|
||||
});
|
||||
|
||||
// ── MOVE_UP ──
|
||||
|
||||
it('MOVE_UP 撤销/重做', () => {
|
||||
mgr.push(
|
||||
makeRecord(OperationType.MOVE_UP, TargetType.SYSTEM, 1, 1, [], []),
|
||||
);
|
||||
mgr.push(makeRecord(OperationType.MOVE_UP, TargetType.SYSTEM, 1, 1, [], []));
|
||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||
|
||||
sysPaths.swap(0, 1);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||
});
|
||||
|
||||
// ── MOVE_DOWN ──
|
||||
|
||||
it('MOVE_DOWN 撤销/重做', () => {
|
||||
mgr.push(
|
||||
makeRecord(OperationType.MOVE_DOWN, TargetType.SYSTEM, 0, 1, [], []),
|
||||
);
|
||||
mgr.push(makeRecord(OperationType.MOVE_DOWN, TargetType.SYSTEM, 0, 1, [], []));
|
||||
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||
|
||||
sysPaths.swap(0, 1);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
});
|
||||
|
||||
// ── CLEAN ──
|
||||
|
||||
it('CLEAN 撤销/重做', () => {
|
||||
const oldPaths = sysPaths.toArray();
|
||||
const newPaths = ['C:\\Windows']; // 假设 Program Files 被清理掉了
|
||||
const old = [...sys];
|
||||
const cleaned = ['C:\\Windows'];
|
||||
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
|
||||
sys = cleaned;
|
||||
|
||||
mgr.push(
|
||||
makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, oldPaths, newPaths),
|
||||
);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(old);
|
||||
|
||||
sysPaths.clear();
|
||||
for (const p of newPaths) sysPaths.add(p);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(newPaths);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(cleaned);
|
||||
});
|
||||
|
||||
// ── CLEAR ──
|
||||
|
||||
it('CLEAR 撤销/重做', () => {
|
||||
const oldPaths = sysPaths.toArray();
|
||||
const old = [...sys];
|
||||
mgr.push(makeRecord(OperationType.CLEAR, TargetType.SYSTEM, 0, 2, old, []));
|
||||
sys = [];
|
||||
|
||||
mgr.push(
|
||||
makeRecord(OperationType.CLEAR, TargetType.SYSTEM, 0, 2, oldPaths, []),
|
||||
);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(old);
|
||||
|
||||
sysPaths.clear();
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.length).toBe(0);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual([]);
|
||||
});
|
||||
|
||||
// ── IMPORT ──
|
||||
|
||||
it('IMPORT 撤销/重做', () => {
|
||||
const oldPaths = sysPaths.toArray();
|
||||
const old = [...sys];
|
||||
const imported = ['C:\\New1', 'C:\\New2'];
|
||||
mgr.push(makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, old, imported));
|
||||
sys = imported;
|
||||
|
||||
mgr.push(
|
||||
makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, oldPaths, imported),
|
||||
);
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[0]).toEqual(old);
|
||||
|
||||
sysPaths.clear();
|
||||
for (const p of imported) sysPaths.add(p);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
||||
|
||||
mgr.redo(sysPaths, userPaths);
|
||||
expect(sysPaths.toArray()).toEqual(imported);
|
||||
const r = mgr.redo(...u)!;
|
||||
expect(r[0]).toEqual(imported);
|
||||
});
|
||||
|
||||
// ── 重做分支截断 ──
|
||||
|
||||
it('新操作后截断重做分支', () => {
|
||||
mgr.push(
|
||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['first']),
|
||||
);
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['first']));
|
||||
mgr.undo(sys, user);
|
||||
expect(mgr.canRedo()).toBe(true);
|
||||
|
||||
// 推入新操作,重做分支被截断
|
||||
mgr.push(
|
||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['second']),
|
||||
);
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['second']));
|
||||
expect(mgr.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
// ── 历史限制 ──
|
||||
|
||||
it('超出最大历史容量时移除最旧记录', () => {
|
||||
const small = new UndoRedoManager(3);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
small.push(
|
||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [`path_${i}`]),
|
||||
);
|
||||
small.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [`path_${i}`]));
|
||||
}
|
||||
expect(small.historyLength).toBe(3);
|
||||
});
|
||||
|
||||
// ── USER 目标 ──
|
||||
|
||||
it('操作 USER 路径', () => {
|
||||
userPaths.add('C:\\NewUserPath');
|
||||
mgr.push(
|
||||
makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']),
|
||||
);
|
||||
|
||||
mgr.undo(sysPaths, userPaths);
|
||||
expect(userPaths.toArray()).toEqual(['C:\\Users\\me\\AppData']);
|
||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
user.push('C:\\NewUserPath');
|
||||
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']));
|
||||
const u = mgr.undo(sys, user)!;
|
||||
expect(u[1]).toEqual(['C:\\Users\\me\\AppData']);
|
||||
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Strict */
|
||||
"strict": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
Reference in New Issue
Block a user