mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}。它们在回收站中。"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user