feat(project-tree): Undo restores files deleted in the Project view (#1103)

Ctrl+Z / Cmd+Z in the Project view (and Edit › Undo while it has focus) reverts the latest file operation there: a delete as well as a move or rename. Deleted files come back from their last Local History snapshot, folders are re-created and the restored items selected; items without a snapshot are named in a warning. Edit › Undo reaches the view through a new registerUndoHandler contribution; the Project view is found by a data attribute instead of its translated aria-label (also fixes the New popup's fallback anchor).
This commit is contained in:
l0ng-ai
2026-11-04 04:53:48 +08:00
committed by GitHub
parent fe0ee338f4
commit 5ad5dc2a28
8 changed files with 184 additions and 11 deletions
+2 -1
View File
@@ -3,7 +3,7 @@ import { EditorView } from "@codemirror/view";
import type { Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { maybeEditorService, notifications, runUndoHandlers } from "@/workbench/contributions";
import { showAbout } from "./AboutDialog";
import { quitApp } from "./closeGuard";
import { guessOs } from "./host";
@@ -46,6 +46,7 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
(command === "undo" ? undo : redo)(view);
return;
}
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
}
if (command === "paste" && navigator.clipboard?.readText) {
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
+4 -4
View File
@@ -60,7 +60,6 @@ import { relativePath } from "@/platform/project";
import { folderKind } from "./folderKind";
import { useHomeDir } from "./home";
import { keyLike, memberNodes, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import {
cancelEditing,
checkEditingName,
@@ -77,6 +76,7 @@ import {
paste,
refreshOsClipboard,
targetFolder,
undoLastFileOperation,
} from "./operations";
import { isInside, nameOf, parentOf, splitExtension, tildify } from "./paths";
import {
@@ -111,7 +111,7 @@ export function showNewPopup(): boolean {
const lead = treeHandle.current?.leadKey();
const row = lead ? document.querySelector<HTMLElement>(`[data-project-key="${CSS.escape(lead)}"]`) : null;
const rect = (
row ?? document.querySelector<HTMLElement>('[role="tree"][aria-label="Project"]')
row ?? document.querySelector<HTMLElement>('[data-project-view] [role="tree"]')
)?.getBoundingClientRect();
if (!rect) return false;
openNewPopup({ x: rect.left + 24, y: rect.bottom }, newMenuItems(targetFolder()));
@@ -705,8 +705,7 @@ export function ProjectView() {
openMoveDialog();
break;
case "Meta+Z":
if (lastFileRefactoring()) void undoFileRefactoring();
else handled = false;
handled = undoLastFileOperation();
break;
case "Meta+C":
copySelection(false);
@@ -767,6 +766,7 @@ export function ProjectView() {
<div
ref={containerRef}
className={styles.view}
data-project-view=""
{...OWNS_FILE_DROP}
onKeyDown={onKeyDown}
// The native Edit menu may take ⌘C/⌘X/⌘V before the keymap sees them; it then sends DOM clipboard events.
+6 -2
View File
@@ -208,7 +208,11 @@ async function withEditorsFollowing<T>(run: () => Promise<T>, done: (r: T) => Fi
// ---------------------------------------------------------------------------
// Run / undo
let lastMove: { id: number; title: string } | null = null;
let lastMove: { id: number; title: string; seq: number } | null = null;
let fileOperations = 0;
/** Orders file operations (move / rename, delete) for Undo, which reverts the latest. */
export const nextFileOperation = () => ++fileOperations;
/** The latest file refactoring that Undo reverts. */
export function lastFileRefactoring() {
@@ -270,7 +274,7 @@ export async function runFileRefactoring(
});
return "failed";
}
lastMove = { id, title };
lastMove = { id, title, seq: nextFileOperation() };
if (collected.errors.length)
notifications().notify({
level: "warning",
+10 -1
View File
@@ -12,6 +12,7 @@ import {
registerOverlay,
registerProjectViewRoot,
registerToolWindow,
registerUndoHandler,
useLocalChanges,
useProjectViewRoots,
} from "@/workbench/contributions";
@@ -22,7 +23,7 @@ import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from "./HeaderActions";
import { loadHomeDir } from "./home";
import { selectedNodes } from "./operations";
import { selectedNodes, undoLastFileOperation } from "./operations";
import { ProjectView } from "./ProjectView";
import {
applyFsChange,
@@ -157,6 +158,14 @@ export function activate(): () => void {
registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }),
registerOverlay({ id: "project.newPopup", component: NewPopupHost }),
registerProjectActions(),
// Edit › Undo while the Project view has focus.
registerUndoHandler(
(command, focused) =>
command === "undo" &&
focused.getAttribute("role") === "tree" &&
!!focused.closest("[data-project-view]") &&
undoLastFileOperation(),
),
provideProjectViewSelection(() =>
selectedNodes()
.filter((n) => !n.group)
+62 -2
View File
@@ -8,10 +8,10 @@ import { executeAction, getAction } from "@/platform/actions";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { ask, confirmChecked } from "@/workbench/messages";
import { osClipboard } from "./clipboard";
import { runFileRefactoring } from "./fileRefactor";
import { lastFileRefactoring, nextFileOperation, runFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
import { beginLocalImport, projectFs } from "./fs";
import { isCancelled } from "@/platform/ipc";
import { call, commands, isCancelled } from "@/platform/ipc";
import { MEMBER_PREFIX, type ProjectNode } from "./model";
import {
isInside,
@@ -459,20 +459,80 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
const fs = projectFs(root);
const parents = new Set<string>();
const errors: string[] = [];
const deleted: string[] = [];
for (const p of paths) {
try {
await fs.delete(p);
parents.add(parentOf(p));
deleted.push(p);
} catch (e) {
errors.push(`${nameOf(p)}: ${errorMessage(e)}`);
}
}
if (deleted.length) {
const dirs = new Set(nodes.filter((n) => n.dir).map((n) => n.path));
lastDelete = { paths: deleted, dirs: deleted.filter((p) => dirs.has(p)), seq: nextFileOperation() };
}
if (errors.length)
notifications().notify({ level: "error", title: "Some items could not be deleted", body: errors.join("\n") });
await Promise.all([...parents].map((d) => loadDir(d, true)));
if (next) revealPath(next, refocus);
}
/** The last Delete in the Project view; Undo brings its files back from Local History. */
let lastDelete: { paths: string[]; dirs: string[]; seq: number } | null = null;
/** Undo in the Project view: reverts the latest delete or move / rename; false when there is none. */
export function undoLastFileOperation(): boolean {
const move = lastFileRefactoring();
if (lastDelete && (!move || lastDelete.seq > move.seq)) {
void undoDelete();
return true;
}
if (!move) return false;
void undoFileRefactoring();
return true;
}
/** Restores the deleted files (and folders) from their last Local History snapshots. */
export async function undoDelete(): Promise<boolean> {
const op = lastDelete;
const { root } = get();
if (!op || !root) return false;
lastDelete = null;
const fs = projectFs(root);
const failed: string[] = [];
const parents = new Set<string>();
for (const p of op.paths) {
try {
const history = await call(commands.historyListDir(p));
const gone = new Set(history.deleted);
const latest = new Map<string, string>();
for (const e of history.entries)
if (e.kind === "snapshot" && e.id && gone.has(e.path) && !latest.has(e.path)) latest.set(e.path, e.id);
const dir = op.dirs.includes(p);
if (!latest.size && !dir) throw new Error("no Local History snapshot");
if (dir) await fs.createDir(p);
for (const [path, id] of latest) await call(commands.historyRestore(path, id));
parents.add(parentOf(p));
} catch {
failed.push(nameOf(p));
}
}
await Promise.all([...parents].map((d) => loadDir(d, true)));
const restored = op.paths.find((p) => !failed.includes(nameOf(p)));
if (restored) revealPath(restored, true);
const names = failed.join(", ");
if (failed.length)
notifications().notify({
level: "warning",
title: "Some items could not be restored",
body: tx`Not in Local History: ${names}. They are in the Trash.`,
key: "project.undoDelete",
});
return !failed.length;
}
// ---------------------------------------------------------------------------
// Clipboard / move / copy
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const history = vi.hoisted(() => ({
restored: [] as Array<[string, string]>,
snapshots: [] as Array<{ id: string; path: string }>,
}));
vi.mock("@/workbench/messages", () => ({
confirm: async () => true,
confirmChecked: async () => ({ checked: false }),
ask: async () => ({ button: "cancel", checked: false }),
}));
vi.mock("@/editor/refactor", () => ({
fileUsages: async () => ({ status: "ok", usages: [] }),
askUsagesDetected: async () => "cancel",
viewUsages: () => {},
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
return {
...real,
commands: {
...real.commands,
// Local History as the backend keeps it: deleted files' snapshots, newest first.
historyListDir: (dir: string) => {
const entries = history.snapshots
.filter((s) => s.path === dir || s.path.startsWith(`${dir}/`))
.map((s) => ({ ...s, timestampMs: 0, label: "Deleted", size: 1, kind: "snapshot" }));
return ok({ entries, deleted: [...new Set(entries.map((e) => e.path))] });
},
historyRestore: (path: string, id: string) => {
history.restored.push([path, id]);
return fs.createFile(path).then(() => ok(null));
},
},
};
});
const { MemoryFs, setProjectFsForTests } = await import("./fs");
const { currentModel, initProjectTree, loadDir } = await import("./store");
const { deleteSelection, undoLastFileOperation } = await import("./operations");
let fs: InstanceType<typeof MemoryFs>;
describe("Undo in the Project view", () => {
beforeEach(() => {
fs = new MemoryFs({ "/p/a.txt": "a", "/p/lib/x.ts": "x", "/p/lib/empty/": null }, "/p");
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "proj");
history.restored.length = 0;
history.snapshots = [
{ id: "new-a", path: "/p/a.txt" },
{ id: "old-a", path: "/p/a.txt" },
{ id: "x1", path: "/p/lib/x.ts" },
];
});
afterEach(() => {
setProjectFsForTests(null);
initProjectTree(null);
});
const labels = (dir: string) => (currentModel().children.get(dir) ?? []).map((n) => n.label);
it("brings deleted files and folders back from their latest Local History snapshots", async () => {
await loadDir("/p");
expect(labels("/p")).toEqual(["lib", "a.txt"]);
await deleteSelection([currentModel().byKey.get("/p/a.txt")!, currentModel().byKey.get("/p/lib")!]);
expect(labels("/p")).toEqual([]);
expect(undoLastFileOperation()).toBe(true);
await vi.waitFor(() => expect(labels("/p")).toEqual(["lib", "a.txt"]));
expect(history.restored).toEqual([
["/p/a.txt", "new-a"],
["/p/lib/x.ts", "x1"],
]);
// Undone once: nothing left to undo.
expect(undoLastFileOperation()).toBe(false);
});
});
+3 -1
View File
@@ -5784,5 +5784,7 @@
"Unexpected token": "意外的标记",
"Syntax": "语法",
"Syntax errors": "语法错误",
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。"
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。",
"Some items could not be restored": "部分项目无法恢复",
"Not in Local History: {names}. They are in the Trash.": "本地历史中没有:{names}。它们在回收站中。"
}
+18
View File
@@ -1211,6 +1211,24 @@ export function occurrenceNavigator(toolWindow: string): OccurrenceNavigator | n
return occurrenceNavigators.get(toolWindow)?.at(-1) ?? null;
}
// ---------------------------------------------------------------------------
// Edit › Undo / Redo outside text fields and editors
/** Undo / Redo for `focused` in a view with its own history (Project view file operations); true when handled. */
export type UndoHandler = (command: "undo" | "redo", focused: Element) => boolean;
const undoHandlers = new Set<UndoHandler>();
export function registerUndoHandler(handler: UndoHandler): () => void {
undoHandlers.add(handler);
return () => void undoHandlers.delete(handler);
}
/** Offers Undo / Redo to the registered views, latest first. */
export function runUndoHandlers(command: "undo" | "redo", focused: Element): boolean {
return [...undoHandlers].reverse().some((h) => h(command, focused));
}
/** Registers `nav` (the latest render's) for `toolWindow` while the calling view is mounted. */
export function useOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator["go"]) {
const latest = useRef(nav);