cloud-drive-renamer/docs/plans/2026-03-18-last-batch-undo-implementation-plan.md
Zain bf75cdcb10 feat: 为批量重命名面板增加撤销最近一次操作功能
- 在平台适配器中统一暴露 getCurrentDirectoryKey() 方法,用于确定当前目录范围
- 新增 LastRenameOperation 数据模型和核心操作函数,支持单条撤销记录的创建、合并和范围校验
- 在 file-selector-panel 中集成撤销逻辑,支持执行后保存记录、retry 成功后合并记录以及撤销操作
- 在 config-panel 底部操作区新增撤销按钮,根据当前目录范围动态显示/隐藏
- 添加多语言支持,包含撤销相关文案
- 添加单元测试和集成测试,确保撤销功能的正确性和稳定性
2026-03-18 23:14:53 +08:00

11 KiB

Last Batch Undo Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 为批量重命名面板增加“仅撤销当前目录最近一次批量重命名”的能力,并让 retry 成功项并入同一条可撤销记录。

Architecture: 在平台适配器层统一暴露 getCurrentDirectoryKey(),在 file-selector-panel 维护单条 LastRenameOperation,通过纯函数帮助模块完成记录创建、合并、scope 校验和失败项收窄,再由 config-panel 提供单按钮 UI。执行与撤销都复用 BatchExecutor,避免重复实现节流、进度和错误汇总逻辑。

Tech Stack: TypeScript, Lit Web Components, Vitest, Playwright(回归可选)


Task 1: 增加统一目录 scope API

Files:

  • Modify: src/types/platform.ts
  • Modify: src/adapters/base/adapter.interface.ts
  • Modify: src/adapters/quark/quark.ts
  • Modify: src/adapters/aliyun/aliyun-adapter.ts
  • Modify: src/adapters/baidu/baidu-adapter.ts
  • Modify: tests/unit/executor.test.ts
  • Modify: tests/integration/batch-execution.test.ts
  • Modify: tests/integration/folder-batch-execution.test.ts
  • Modify: tests/performance/benchmark.test.ts

Step 1: 写一个最小失败测试(或先让 typecheck 暴露接口缺失)

在 tests/unit/executor.test.ts 的 mock adapter 中先补一条注释,准备添加:

getCurrentDirectoryKey(): string {
  return 'root';
}

Step 2: 修改平台接口与抽象基类

在 src/types/platform.ts 增加:

getCurrentDirectoryKey(): string;

在 src/adapters/base/adapter.interface.ts 增加抽象声明:

abstract getCurrentDirectoryKey(): string;

Step 3: 在三个平台适配器中实现该方法

目标实现:

getCurrentDirectoryKey(): string {
  return this.getCurrentFolderId(); // Quark
}
getCurrentDirectoryKey(): string {
  return this.getParentIdFromUrl(); // Aliyun
}
getCurrentDirectoryKey(): string {
  return this.getCurrentPath(); // Baidu
}

Step 4: 更新所有测试 mock adapter

给所有 implements PlatformAdapter 的 mock 增加:

getCurrentDirectoryKey(): string {
  return 'root';
}

Step 5: 运行定向类型检查/测试

Run:

npm run typecheck
npx vitest run tests/unit/executor.test.ts tests/integration/batch-execution.test.ts

Expected: 新接口编译通过,mock 不再缺少 getCurrentDirectoryKey()。

Step 6: Commit

git add src/types/platform.ts src/adapters/base/adapter.interface.ts src/adapters/quark/quark.ts src/adapters/aliyun/aliyun-adapter.ts src/adapters/baidu/baidu-adapter.ts tests/unit/executor.test.ts tests/integration/batch-execution.test.ts tests/integration/folder-batch-execution.test.ts tests/performance/benchmark.test.ts
git commit -m "refactor: expose current directory scope on adapters"

Task 2: 提取单条 undo 记录帮助模块(TDD)

Files:

  • Create: src/types/undo.ts
  • Create: src/core/last-rename-operation.ts
  • Create: tests/unit/last-rename-operation.test.ts

Step 1: 先写失败测试

在 tests/unit/last-rename-operation.test.ts 写出以下核心用例:

it('creates a last rename operation from success results', () => {
  const op = createLastRenameOperation('quark', 'dir-1', [
    { fileId: '1', original: 'a.txt', renamed: 'b.txt', index: 0 },
  ]);
  expect(op?.items).toHaveLength(1);
  expect(op?.items[0].original).toBe('a.txt');
});

it('merges retry success items into the existing operation', () => {
  const merged = mergeLastRenameOperation(existing, [
    { fileId: '2', original: 'c.txt', renamed: 'd.txt', index: 1 },
  ]);
  expect(merged.items.map(i => i.fileId)).toEqual(['1', '2']);
});

it('shrinks the operation to failed undo items only', () => {
  const next = retainFailedUndoItems(existing, new Set(['2']));
  expect(next?.items.map(i => i.fileId)).toEqual(['2']);
});

Step 2: 运行测试确认失败

Run:

npx vitest run tests/unit/last-rename-operation.test.ts

Expected: FAIL,提示模块或导出不存在。

Step 3: 实现最小类型与纯函数

在 src/types/undo.ts 增加:

export interface UndoRenameItem {
  fileId: string;
  original: string;
  renamed: string;
  index: number;
}

export interface LastRenameOperation {
  platform: PlatformName;
  directoryKey: string;
  createdAt: number;
  updatedAt: number;
  items: UndoRenameItem[];
}

在 src/core/last-rename-operation.ts 实现:

  • createLastRenameOperation(...)
  • mergeLastRenameOperation(...)
  • isLastRenameOperationInScope(...)
  • retainFailedUndoItems(...)

Step 4: 再次运行测试

Run:

npx vitest run tests/unit/last-rename-operation.test.ts

Expected: PASS

Step 5: Commit

git add src/types/undo.ts src/core/last-rename-operation.ts tests/unit/last-rename-operation.test.ts
git commit -m "test: cover last rename undo operation helpers"

Task 3: 在 file-selector-panel 中接入记录、retry 合并与 undo 主逻辑

Files:

  • Modify: src/content/components/file-selector-panel.ts
  • Modify: src/types/core.ts(如需补充 undo 结果或辅助类型,否则保持不动)

Step 1: 先写失败测试或至少列出要补的帮助方法

如果暂不引入组件测试,先在代码注释中列出 4 个目标方法:

private getCurrentUndoScope(): { platform: PlatformName; directoryKey: string }
private clearLastRenameOperationIfOutOfScope(): void
private updateLastRenameOperationFromExecute(results: BatchResults): void
private async handleUndoLastRename(): Promise<void>

Step 2: 增加状态字段

在 file-selector-panel.ts 增加:

@state()
private lastRenameOperation: LastRenameOperation | null = null;

@state()
private undoBusy = false;

Step 3: 在关键入口先做 scope 校验

在这些方法开头调用:

this.clearLastRenameOperationIfOutOfScope();

目标入口:

  • updated()(当 open 变为 true 时)
  • handleExecute()
  • handleRetryFailed()
  • handleUndoLastRename()

Step 4: 在主执行后创建单条记录

在 handleExecute() 成功路径里:

if (results.success.length > 0) {
  this.lastRenameOperation = createLastRenameOperation(
    this.adapter.platform,
    this.adapter.getCurrentDirectoryKey(),
    results.success
  );
}

Step 5: 在 retry 成功后合并记录

在 handleRetryFailed() 成功路径里:

if (results.success.length > 0 && this.lastRenameOperation) {
  this.lastRenameOperation = mergeLastRenameOperation(
    this.lastRenameOperation,
    this.adapter.platform,
    this.adapter.getCurrentDirectoryKey(),
    results.success
  );
}

Step 6: 实现 undo 执行

核心逻辑:

const tasks = this.lastRenameOperation.items.map((item, index) => ({
  file: this.allFiles.find(file => file.id === item.fileId) ?? {
    id: item.fileId,
    name: item.renamed,
    ext: parseFileName(item.renamed).ext,
    parentId: '',
    size: 0,
    mtime: Date.now(),
  },
  newName: item.original,
  index,
}));

然后复用 BatchExecutor:

const executor = new BatchExecutor(tasks.map(t => t.file), this.ruleConfig, this.adapter, {
  requestInterval: this.adapter.getConfig().requestInterval,
  maxConcurrent: this.adapter.getConfig().maxConcurrent,
  tasks,
  onProgress: (progress) => this.handleProgress(progress),
});

Step 7: 处理撤销后的记录状态

  • 全部成功:this.lastRenameOperation = null
  • 部分失败:只保留失败 fileId 对应的项
const failedIds = new Set(results.failed.map(item => item.fileId));
this.lastRenameOperation = retainFailedUndoItems(this.lastRenameOperation, failedIds);

Step 8: 运行定向测试与类型检查

Run:

npm run typecheck
npx vitest run tests/unit/last-rename-operation.test.ts tests/unit/executor.test.ts

Expected: PASS

Step 9: Commit

git add src/content/components/file-selector-panel.ts src/types/core.ts
git commit -m "feat: add single-operation undo flow"

Task 4: 在 config-panel 加入撤销按钮和多语言文案

Files:

  • Modify: src/content/components/config-panel.ts
  • Modify: src/locales/zh_CN.json
  • Modify: src/locales/en.json
  • Modify: src/locales/zh_TW.json

Step 1: 先加 props 和事件

在 config-panel.ts 增加:

@property({ type: Boolean }) canUndo = false;
@property({ type: Boolean }) undoBusy = false;

以及:

private handleUndo(): void {
  this.dispatchEvent(new CustomEvent('undo', { bubbles: true, composed: true }));
}

Step 2: 在执行区渲染按钮

在 renderExecutionActions() 的非 executing 分支中加入:

<button
  class="button button-default"
  ?disabled=${!this.canUndo || this.undoBusy}
  @click=${this.handleUndo}
>
  ${this.undoBusy ? I18nService.t('undo_in_progress') : I18nService.t('undo_last_rename')}
</button>

并在 file-selector-panel.ts 的 render() 中传入:

.canUndo=${Boolean(this.lastRenameOperation)}
.undoBusy=${this.undoBusy}
@undo=${this.handleUndoLastRename}

Step 3: 增加 i18n 文案

至少新增:

  • undo_last_rename
  • undo_in_progress
  • undo_failed_prefix
  • undo_failed_manual_hint
  • undo_scope_hint

Step 4: 运行定向测试/构建检查

Run:

npm run typecheck
npm run lint

Expected: PASS

Step 5: Commit

git add src/content/components/config-panel.ts src/locales/zh_CN.json src/locales/en.json src/locales/zh_TW.json
git commit -m "feat: add undo action to rename panel"

Task 5: 完成失败提示与整体验证

Files:

  • Modify: src/content/components/file-selector-panel.ts
  • Modify: tests/integration/batch-execution.test.ts(如需新增集成回归样例)
  • Optional: tests/e2e/batch-rename.spec.ts

Step 1: 在 undo 失败分支拼接提示文本

示例:

const failedLines = results.failed.map(item => `- ${item.file.name}: ${item.error}`);
alert([
  I18nService.t('undo_failed_prefix'),
  ...failedLines,
  '',
  I18nService.t('undo_failed_manual_hint'),
].join('\n'));

Step 2: 增加一条集成回归样例(推荐)

目标断言:

  • 首次执行成功 + retry 成功后,唯一记录包含两批成功项
  • undo 后两批文件都恢复原名

Step 3: 运行完整验证

Run:

npm run test:ci
npm run typecheck
npm run lint

Expected: 全部通过

Step 4: 手工验证当前目录 scope

Run:

npm run dev

手工检查:

  1. 在同目录执行批量重命名;
  2. 关闭面板,再打开,undo 按钮仍可用;
  3. 切换目录,再打开面板,undo 按钮消失;
  4. 回原目录(若页面未刷新且采用懒清理,则记录已被清空,这是符合当前规则的);
  5. retry 成功的文件也会被 undo 回退。

Step 5: Commit

git add src/content/components/file-selector-panel.ts tests/integration/batch-execution.test.ts tests/e2e/batch-rename.spec.ts
git commit -m "test: verify last batch undo workflow"