diff --git a/src/features/app/appActions.ts b/src/features/app/appActions.ts index 39f33ce9..383dbf65 100644 --- a/src/features/app/appActions.ts +++ b/src/features/app/appActions.ts @@ -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)); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index b0f1fa9d..c09523c9 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -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(`[data-project-key="${CSS.escape(lead)}"]`) : null; const rect = ( - row ?? document.querySelector('[role="tree"][aria-label="Project"]') + row ?? document.querySelector('[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() {
(run: () => Promise, 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", diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index b4d699fb..efcc5d9a 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -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) diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 0f4711f6..09b1938d 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -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(); 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 { + const op = lastDelete; + const { root } = get(); + if (!op || !root) return false; + lastDelete = null; + const fs = projectFs(root); + const failed: string[] = []; + const parents = new Set(); + for (const p of op.paths) { + try { + const history = await call(commands.historyListDir(p)); + const gone = new Set(history.deleted); + const latest = new Map(); + 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 diff --git a/src/features/project-tree/undoDelete.test.ts b/src/features/project-tree/undoDelete.test.ts new file mode 100644 index 00000000..867283b8 --- /dev/null +++ b/src/features/project-tree/undoDelete.test.ts @@ -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(); + const ok = (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; + +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); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c09faefb..05fe8274 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}。它们在回收站中。" } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 62d0823f..4c116195 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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(); + +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);