Files
PathEditor/src/core/undo-redo.ts
T
Serendipity 9453006310
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
chore: 同步 v5.0 基础设施完善到 v5.1
从 v5.0 cherry-pick 的开源项目基础设施改进:

新增配置文件:
- .editorconfig, .gitattributes, .prettierrc, .markdownlint.json
- commitlint.config.js

新增 GitHub 社区文件:
- .github/dependabot.yml — 依赖自动更新
- .github/CODEOWNERS — 自动 PR 审查分配
- .github/FUNDING.yml — 开源赞助入口

新增文档:
- ROADMAP.md — 路线图
- SUPPORT.md — 帮助指南
- docs/screenshots/ — 应用截图

新增 Git Hooks:
- .husky/pre-commit — lint-staged 自动格式化+修复
- .husky/commit-msg — commitlint 校验

CI 强化:
- 新增 Prettier 格式检查
- 新增 Vitest 覆盖率 + Codecov 上报
- 保留 v5.1 已有的 rust-cache + jsdom 全局环境

修复:
- index.html 标题 v4.0 → v5.1
- PathEditDialog set-state-in-effect 改用 useRef prevOpen 守卫
- merge-preview.test.tsx no-explicit-any 修复
- 所有 TS/TSX 文件 Prettier 格式化统一

v5.1 保留特性:
- @tanstack/react-virtual 虚拟滚动
- jsdom 全局测试环境
- Swatinem/rust-cache CI 加速
- 105 测试全部通过
2026-06-19 19:24:03 +08:00

191 lines
5.1 KiB
TypeScript

/**
* 撤销/重做管理器 — 纯逻辑,操作不可变 PathEntry[]
*/
import type { PathEntry } from './path-entry';
export const OperationType = {
ADD: 0,
DELETE: 1,
EDIT: 2,
MOVE_UP: 3,
MOVE_DOWN: 4,
CLEAN: 5,
CLEAR: 6,
IMPORT: 7,
TOGGLE: 8,
IMPORT_BOTH: 9,
} as const;
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
export const TargetType = { SYSTEM: 0, USER: 1 } as const;
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
export interface OpRecord {
type: OperationType;
target: TargetType;
index: number;
count: number;
oldPaths: PathEntry[];
newPaths: PathEntry[];
/** DELETE 操作专用:被删除的各路径的原始 index(升序) */
indices?: number[];
/** IMPORT_BOTH 专用:用户 hive 的旧路径 */
oldPathsOther?: PathEntry[];
/** IMPORT_BOTH 专用:用户 hive 的新路径 */
newPathsOther?: PathEntry[];
}
const DEFAULT_MAX_SIZE = 50;
export class UndoRedoManager {
private records: OpRecord[] = [];
private current: number = -1;
private readonly maxSize: number;
constructor(maxSize: number = DEFAULT_MAX_SIZE) {
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: readonly PathEntry[],
userPaths: readonly PathEntry[],
): [PathEntry[], PathEntry[]] | null {
if (this.current < 0) return null;
const rec = this.records[this.current];
this.current--;
const sys = [...sysPaths];
const user = [...userPaths];
const target = rec.target === TargetType.SYSTEM ? sys : user;
switch (rec.type) {
case OperationType.ADD:
target.splice(target.length - rec.count, rec.count);
break;
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;
case OperationType.EDIT:
target[rec.index] = rec.oldPaths[0];
break;
case OperationType.MOVE_UP:
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
break;
case OperationType.MOVE_DOWN:
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
break;
case OperationType.CLEAN:
case OperationType.IMPORT:
target.length = 0;
target.push(...rec.oldPaths);
break;
case OperationType.CLEAR:
target.push(...rec.oldPaths);
break;
case OperationType.TOGGLE:
target[rec.index] = rec.oldPaths[0];
break;
case OperationType.IMPORT_BOTH:
sys.length = 0;
sys.push(...rec.oldPaths);
user.length = 0;
user.push(...(rec.oldPathsOther || []));
return [sys, user];
}
return [sys, user];
}
redo(
sysPaths: readonly PathEntry[],
userPaths: readonly PathEntry[],
): [PathEntry[], PathEntry[]] | null {
if (this.current >= this.records.length - 1) return null;
this.current++;
const rec = this.records[this.current];
const sys = [...sysPaths];
const user = [...userPaths];
const target = rec.target === TargetType.SYSTEM ? sys : user;
switch (rec.type) {
case OperationType.ADD:
target.push(...rec.newPaths);
break;
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;
case OperationType.EDIT:
target[rec.index] = rec.newPaths[0];
break;
case OperationType.MOVE_UP:
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
break;
case OperationType.MOVE_DOWN:
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
break;
case OperationType.CLEAN:
case OperationType.IMPORT:
target.length = 0;
target.push(...rec.newPaths);
break;
case OperationType.CLEAR:
target.length = 0;
break;
case OperationType.TOGGLE:
target[rec.index] = rec.newPaths[0];
break;
case OperationType.IMPORT_BOTH:
sys.length = 0;
sys.push(...rec.newPaths);
user.length = 0;
user.push(...(rec.newPathsOther || []));
return [sys, user];
}
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;
}
}