diff --git a/apps/desktop/e2e/backup-recovery.spec.ts b/apps/desktop/e2e/backup-recovery.spec.ts new file mode 100644 index 0000000..66818a1 --- /dev/null +++ b/apps/desktop/e2e/backup-recovery.spec.ts @@ -0,0 +1,48 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { _electron as electron, expect, test } from "@playwright/test"; +import { maximizeWindow } from "./helpers"; + +test("an automatic backup can be restored as a new page without replacing the current page", async () => { + test.setTimeout(60_000); + const home = mkdtempSync(join(tmpdir(), "lucid-backup-")); + const app = await electron.launch({ + args: [join(__dirname, "../out/main/index.js")], + env: { ...process.env, LUCID_HOME: home }, + }); + const page = await app.firstWindow(); + await maximizeWindow(app, page); + + try { + const originalId = await page.evaluate(async () => { + const original = await window.lucid.pages.create("Original"); + await window.lucid.pages.save({ ...original, title: "Current" }); + return original.id; + }); + await page.reload(); + await expect(page.getByLabel("Page title")).toHaveValue("Current"); + + await page.getByRole("button", { name: "Backups" }).click(); + const dialog = page.getByRole("dialog", { name: "Automatic backups" }); + await expect(dialog).toContainText("never replaces this one"); + await expect(dialog).toContainText("Original"); + await dialog.getByRole("button", { name: "Restore as copy" }).click(); + + await expect(page.getByLabel("Page title")).toHaveValue("Original (recovered)"); + const state = await page.evaluate( + async (id) => ({ + original: await window.lucid.pages.load(id), + pages: (await window.lucid.pages.list()).pages, + }), + originalId, + ); + expect(state.original.title).toBe("Current"); + expect(state.pages.map((item) => item.title).sort()).toEqual([ + "Current", + "Original (recovered)", + ]); + } finally { + await app.close(); + } +}); diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index ba90b5f..c2945d0 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -92,6 +92,15 @@ export function registerIpc(): void { ipcMain.handle(IPC.pagesSave, async (_e, file: unknown) => (await library).save(parseLucidFile(file)), ); + ipcMain.handle(IPC.pagesBackups, async (_e, id: unknown) => { + if (typeof id !== "string") throw new Error("invalid page id"); + return (await library).backups(id); + }); + ipcMain.handle(IPC.pagesRestoreBackup, async (_e, id: unknown, backupId: unknown) => { + if (typeof id !== "string" || typeof backupId !== "string") + throw new Error("invalid backup request"); + return (await library).restoreBackup(id, backupId); + }); registerSecureAiIpc(libraryDir()); const lmDeps = async (): Promise => { const s = await loadSettings(libraryDir()); diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts index 6012c03..dbea99c 100644 --- a/apps/desktop/electron/ipc/types.ts +++ b/apps/desktop/electron/ipc/types.ts @@ -15,6 +15,13 @@ export interface PageProblem { fileName: string; message: string; } +export interface PageBackup { + /** Opaque backup name. It is safe to pass back to `restoreBackup`; it is not a path. */ + id: string; + createdAt: string; + title: string; + blockCount: number; +} /** What the File menu asks of the window. */ export type MenuAction = "export-note" | "import-note"; @@ -263,6 +270,8 @@ export const IPC = { pagesLoad: "lucid:pages-load", pagesSave: "lucid:pages-save", pagesTrash: "lucid:pages-trash", + pagesBackups: "lucid:pages-backups", + pagesRestoreBackup: "lucid:pages-restore-backup", pdfCompile: "lucid:pdf-compile", pdfSave: "lucid:pdf-save", pdfOpen: "lucid:pdf-open", @@ -311,6 +320,9 @@ export interface LucidApi { load(id: string): Promise; save(file: LucidFile): Promise; trash(id: string): Promise; + backups(id: string): Promise<{ backups: PageBackup[]; problems: PageProblem[] }>; + /** Restores an automatic snapshot as a new page, without replacing the current page. */ + restoreBackup(id: string, backupId: string): Promise; }; settings: { get(): Promise; diff --git a/apps/desktop/electron/library.test.ts b/apps/desktop/electron/library.test.ts index c5db2ea..fcfc5ec 100644 --- a/apps/desktop/electron/library.test.ts +++ b/apps/desktop/electron/library.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { LucidFileError } from "@lucid/core"; @@ -156,6 +156,58 @@ describe("Library: save", () => { }); }); +describe("Library: backup recovery", () => { + it("lists valid snapshots newest first with useful metadata", async () => { + const l = lib({ backupEveryMs: 0 }); + const page = await l.create("Original"); + await l.save({ ...page, title: "First edit" }); + tick(1000); + await l.save({ ...page, title: "Second edit" }); + + const result = await l.backups(page.id); + expect(result.problems).toEqual([]); + expect(result.backups.map((backup) => backup.title)).toEqual(["First edit", "Original"]); + expect(result.backups[0]).toEqual( + expect.objectContaining({ + id: "2026-10-10T10-00-01-000Z.lucid", + createdAt: "2026-10-10T10:00:01.000Z", + blockCount: 0, + }), + ); + }); + + it("restores a snapshot as a new page without changing the current page", async () => { + const l = lib({ backupEveryMs: 0 }); + const page = await l.create("Original"); + const current = await l.save({ ...page, title: "Current" }); + const backup = (await l.backups(page.id)).backups[0]; + expect(backup).toBeDefined(); + + const restored = await l.restoreBackup(page.id, backup?.id ?? ""); + expect(restored.id).not.toBe(page.id); + expect(restored.title).toBe("Original (recovered)"); + expect((await l.load(page.id)).title).toBe(current.title); + expect((await l.list()).pages).toHaveLength(2); + }); + + it("reports damaged snapshots without touching them and refuses unknown backup names", async () => { + const l = lib(); + const page = await l.create("Original"); + const backupDir = join(dir, ".lucid", "backups", page.id); + await mkdir(backupDir, { recursive: true }); + const damaged = join(backupDir, "2026-10-10T10-00-00-000Z.lucid"); + await writeFile(damaged, "{ broken", "utf8"); + + const result = await l.backups(page.id); + expect(result.backups).toEqual([]); + expect(result.problems).toHaveLength(1); + expect(await readFile(damaged, "utf8")).toBe("{ broken"); + await expect(l.restoreBackup(page.id, "../outside.lucid")).rejects.toThrow( + /no longer available/, + ); + }); +}); + describe("Library: trash and damaged files", () => { it("moves a page to the Trash instead of deleting it", async () => { const l = lib(); diff --git a/apps/desktop/electron/library.ts b/apps/desktop/electron/library.ts index 4c0c4fe..d6337da 100644 --- a/apps/desktop/electron/library.ts +++ b/apps/desktop/electron/library.ts @@ -38,6 +38,14 @@ export interface Problem { message: string; } +export interface PageBackup { + /** Opaque backup name used to restore this snapshot; never a filesystem path. */ + id: string; + createdAt: string; + title: string; + blockCount: number; +} + export interface LibraryOptions { now?: () => Date; /** Move a file to the Trash (the app passes Electron's shell.trashItem). Never hard-deletes. */ @@ -49,7 +57,7 @@ export interface LibraryOptions { /** Backups are named with their time (`2026-10-10T10-00-00-000Z.lucid`); read it back. Unreadable names count as old. */ function backupTime(fileName: string): number { - const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(fileName); + const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.lucid$/.exec(fileName); return m ? Date.parse(`${m[1] ?? ""}T${m[2] ?? ""}:${m[3] ?? ""}:${m[4] ?? ""}.${m[5] ?? ""}Z`) : 0; @@ -73,6 +81,11 @@ export class Library { return join(this.dir, fileName); } + private backupDir(id: string): string { + if (!/^[a-z0-9-]+$/i.test(id)) throw new Error("invalid page id"); + return join(this.dir, ".lucid", "backups", id); + } + private async ensure(): Promise { await mkdir(this.dir, { recursive: true }); } @@ -150,6 +163,61 @@ export class Library { return this.read(name); } + /** Valid automatic snapshots for a page, newest first. Damaged snapshots are reported and left untouched. */ + async backups(id: string): Promise<{ backups: PageBackup[]; problems: Problem[] }> { + // A renderer cannot use this endpoint to explore arbitrary backup folders. + if (!(await this.findFileName(id))) throw new Error("That page doesn't exist any more."); + const dir = this.backupDir(id); + let names: string[]; + try { + names = (await readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && backupTime(entry.name) > 0) + .map((entry) => entry.name) + .sort() + .reverse(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { backups: [], problems: [] }; + throw error; + } + + const backups: PageBackup[] = []; + const problems: Problem[] = []; + for (const name of names) { + try { + const raw = JSON.parse(await readFile(join(dir, name), "utf8")) as unknown; + const file = parseLucidFile(raw); + registry.validateDoc(file.doc); + if (file.id !== id) throw new Error("This backup belongs to another page."); + backups.push({ + id: name, + createdAt: new Date(backupTime(name)).toISOString(), + title: file.title, + blockCount: file.doc.blocks.length, + }); + } catch (error) { + problems.push({ + fileName: name, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return { backups, problems }; + } + + /** Restores one automatic snapshot as a new page. The current page and its history are never overwritten. */ + async restoreBackup(id: string, backupId: string): Promise { + const available = await this.backups(id); + if (!available.backups.some((backup) => backup.id === backupId)) + throw new Error("That backup is no longer available."); + const raw = JSON.parse(await readFile(join(this.backupDir(id), backupId), "utf8")) as unknown; + const snapshot = parseLucidFile(raw); + registry.validateDoc(snapshot.doc); + if (snapshot.id !== id) throw new Error("This backup belongs to another page."); + const existing = new Set((await this.list()).pages.map((page) => page.id)); + const { file } = planImport(snapshot, existing); + return this.save({ ...file, title: `${file.title.replace(/ \(copy\)$/, "")} (recovered)` }); + } + /** Save a page. The title decides the file name; when it changes the page moves to its new name. */ async save(file: LucidFile): Promise { const existing = await this.findFileName(file.id); @@ -200,7 +268,7 @@ export class Library { } private async backupIfDue(fileName: string, id: string): Promise { - const dir = join(this.dir, ".lucid", "backups", id.replace(/[^a-z0-9-]/gi, "")); + const dir = this.backupDir(id); await mkdir(dir, { recursive: true }); const names = (await readdir(dir)).filter((n) => n.endsWith(".lucid")).sort(); const last = names.at(-1); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index fe55074..d7463af 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -46,6 +46,12 @@ const api: LucidApi = { save: (file) => ipcRenderer.invoke(IPC.pagesSave, file) as ReturnType, trash: (id) => ipcRenderer.invoke(IPC.pagesTrash, id) as Promise, + backups: (id) => + ipcRenderer.invoke(IPC.pagesBackups, id) as ReturnType, + restoreBackup: (id, backupId) => + ipcRenderer.invoke(IPC.pagesRestoreBackup, id, backupId) as ReturnType< + LucidApi["pages"]["restoreBackup"] + >, }, note: { export: (file, options) => diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5d016fd..f591d6d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -135,6 +135,7 @@ function Shell() { key={pages.current.id} file={pages.current} onSaved={pages.saved} + onRestoreBackup={pages.restoreBackup} sidebarOpen={sidebarOpen} onToggleSidebar={toggleSidebar} /> diff --git a/apps/desktop/src/pages/BackupDialog.tsx b/apps/desktop/src/pages/BackupDialog.tsx new file mode 100644 index 0000000..b096634 --- /dev/null +++ b/apps/desktop/src/pages/BackupDialog.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from "react"; +import type { PageBackup, PageProblem } from "../../electron/ipc/types"; +import { Icon } from "../ui/Icon"; + +interface Props { + pageId: string; + title: string; + onClose: () => void; + onRestore: (backupId: string) => Promise; +} + +/** Lists Lucid's automatic snapshots and restores one safely as a separate page. */ +export function BackupDialog({ pageId, title, onClose, onRestore }: Props) { + const [backups, setBackups] = useState([]); + const [problems, setProblems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [restoring, setRestoring] = useState(); + const close = useRef(null); + + useEffect(() => { + close.current?.focus(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && restoring === undefined) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + }; + }, [onClose, restoring]); + + useEffect(() => { + let active = true; + void window.lucid.pages + .backups(pageId) + .then((result) => { + if (!active) return; + setBackups(result.backups); + setProblems(result.problems); + }) + .catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [pageId]); + + const restore = async (backupId: string) => { + setRestoring(backupId); + setError(undefined); + try { + await onRestore(backupId); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setRestoring(undefined); + } + }; + + return ( +
+
{ + event.stopPropagation(); + }} + > +
+

Automatic backups

+ +
+

+ Earlier versions of {title.trim() || "Untitled"}. Restoring makes a new page; it + never replaces this one. +

+ {loading ? ( +

+ Looking for backups… +

+ ) : backups.length === 0 ? ( +

No automatic backups yet.

+ ) : ( +
    + {backups.map((backup) => ( +
  • +
    + {new Date(backup.createdAt).toLocaleString()} + + {backup.title} · {String(backup.blockCount)} block + {backup.blockCount === 1 ? "" : "s"} + +
    + +
  • + ))} +
+ )} + {problems.length > 0 && ( +

+ {String(problems.length)} damaged backup{problems.length === 1 ? " was" : "s were"} + {" ignored and left untouched."} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/pages/Workspace.tsx b/apps/desktop/src/pages/Workspace.tsx index 29531d7..f969264 100644 --- a/apps/desktop/src/pages/Workspace.tsx +++ b/apps/desktop/src/pages/Workspace.tsx @@ -28,6 +28,7 @@ import { peekSettings, useAutoSendMs } from "../settings/settingsStore"; import { useAppearance } from "../theme/appearance"; import { HistoryPanel } from "./HistoryPanel"; import { useAutosave } from "./useAutosave"; +import { BackupDialog } from "./BackupDialog"; const SIDE_KEY = "lucid.history"; @@ -64,6 +65,7 @@ interface Props { /** Sidebar (page list) state, so the top bar can bring it back. */ sidebarOpen: boolean; onToggleSidebar: () => void; + onRestoreBackup: (pageId: string, backupId: string) => Promise; } /** @@ -71,7 +73,7 @@ interface Props { * lists what was said. The page is edited by speaking or typing in the box; Edit mode lets you type in the page * itself for the few things the model gets wrong. */ -export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props) { +export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar, onRestoreBackup }: Props) { const [title, setTitle] = useState(file.title); const [doc, setDoc] = useState(file.doc); const [transcripts, setTranscripts] = useState(file.transcripts); @@ -79,6 +81,7 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props const [format, setFormat] = useState(file.meta.format); const [info, setInfo] = useState>(file.meta.info); const [sideOpen, setSideOpen] = useState(sideOpenAtStart); + const [backupsOpen, setBackupsOpen] = useState(false); const [draft, setDraft] = useState< { text: string; mode: "input" | "command"; key: number } | undefined >(); @@ -410,6 +413,16 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props )}
+
); } diff --git a/apps/desktop/src/pages/usePages.ts b/apps/desktop/src/pages/usePages.ts index cf4d089..853e3be 100644 --- a/apps/desktop/src/pages/usePages.ts +++ b/apps/desktop/src/pages/usePages.ts @@ -140,5 +140,34 @@ export function usePages() { }); }, []); - return { pages, problems, current, ready, error, open, create, trash, saved, reload }; + /** Recover an automatic snapshot as a new page and open it, leaving the current page untouched. */ + const restoreBackup = useCallback(async (id: string, backupId: string) => { + try { + const file = await window.lucid.pages.restoreBackup(id, backupId); + const listed = await window.lucid.pages.list(); + opening.current++; + setPages(listed.pages); + setProblems(listed.problems); + setCurrent(file); + remember(file.id); + setError(undefined); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + throw reason; + } + }, []); + + return { + pages, + problems, + current, + ready, + error, + open, + create, + trash, + saved, + reload, + restoreBackup, + }; } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 27c4772..29753a3 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -205,6 +205,62 @@ select:focus-visible, margin: 12px 0 0; line-height: 1.55; } +.backup-dialog { + width: min(680px, 100%); +} +.backup-dialog > p { + line-height: 1.5; +} +.backup-list { + display: grid; + gap: 8px; + margin: 16px 0 0; + padding: 0; + list-style: none; +} +.backup-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; +} +.backup-list li > div { + min-width: 0; + display: grid; + gap: 3px; +} +.backup-list span, +.backup-empty { + color: var(--ink-2); + font-size: 12.5px; +} +.backup-list .ghost { + flex: none; +} +.backup-warning, +.backup-error { + margin: 12px 0 0; + padding: 8px 10px; + border-radius: 6px; + font-size: 12.5px; +} +.backup-warning { + color: var(--ink-2); + background: var(--panel); +} +.backup-error { + color: var(--red); + background: var(--red-bg); +} +@media (max-width: 560px) { + .backup-list li { + align-items: stretch; + flex-direction: column; + } +} .first-steps { max-width: 46ch; margin: 28px auto 0;