import { beforeEach, describe, expect, it } from "vitest"; import { TFile } from "obsidian"; import type { App } from "obsidian"; import { depth, forget, record, redo, transaction, undo } from "../src/vault/history"; /** Obsidian hands out TFile instances; history checks for them by identity. */ function handle(path: string): TFile { return Object.assign(Object.create(TFile.prototype) as TFile, { path }); } function vault(seed: Record) { const files = new Map(Object.entries(seed)); const handles = new Map([...files.keys()].map((path) => [path, handle(path)])); const app = { vault: { getAbstractFileByPath: (path: string) => handles.get(path) ?? null, read: async (file: TFile) => files.get(file.path) ?? "", process: async (file: TFile, fn: (data: string) => string) => { const next = fn(files.get(file.path) ?? ""); files.set(file.path, next); return next; }, }, } as unknown as App; return { app, files }; } /** Mirrors what mutate.edit does: write the file, then log both sides. */ async function write( { app, files }: ReturnType, path: string, next: string, ): Promise { const before = files.get(path) ?? ""; await app.vault.process( app.vault.getAbstractFileByPath(path) as TFile, () => next, ); record(path, before, next); } describe("history", () => { beforeEach(() => forget()); it("puts a single change back", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "two"); await undo(store.app); expect(store.files.get("a.md")).toBe("one"); }); it("replays what it undid", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "two"); await undo(store.app); await redo(store.app); expect(store.files.get("a.md")).toBe("two"); }); it("walks back through several changes one at a time", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "two"); await write(store, "a.md", "three"); await undo(store.app); expect(store.files.get("a.md")).toBe("two"); await undo(store.app); expect(store.files.get("a.md")).toBe("one"); }); it("takes both files of a move back together", async () => { const store = vault({ "a.md": "task", "b.md": "" }); await transaction("the move", async () => { await write(store, "b.md", "task"); await write(store, "a.md", ""); }); expect(depth().past).toBe(1); await undo(store.app); expect(store.files.get("a.md")).toBe("task"); expect(store.files.get("b.md")).toBe(""); }); it("ignores a write that changed nothing", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "one"); expect(depth().past).toBe(0); }); it("refuses to touch a file that moved on underneath it", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "two"); store.files.set("a.md", "edited elsewhere"); await undo(store.app); expect(store.files.get("a.md")).toBe("edited elsewhere"); // The step is kept, so a later undo can still work if the file comes back. expect(depth().past).toBe(1); }); it("drops the redo trail once something new is written", async () => { const store = vault({ "a.md": "one" }); await write(store, "a.md", "two"); await undo(store.app); expect(depth().future).toBe(1); await write(store, "a.md", "three"); expect(depth().future).toBe(0); }); it("says so instead of throwing when there is nothing left", async () => { const store = vault({ "a.md": "one" }); await expect(undo(store.app)).resolves.toBeUndefined(); await expect(redo(store.app)).resolves.toBeUndefined(); }); });