From aaa491424d1acc56bffef09293dfe5fc390e7e88 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 20 Aug 2026 18:12:33 -0500 Subject: [PATCH] Add Undo for book and chapter deletions - Show an Undo toast after deleting a book from Home; Undo restores the folder from the system trash (XDG .trashinfo, URL-decoded path match). - Chapter delete/cut now snapshots the chapter (title, color, content, position) and offers Undo to restore it in place. - Add reusable showUndoToast/hideUndoToast and #undoToast UI + styles; expose restoreBook/restoreChapter over the preload bridge. - Drop the now-redundant 'cannot be undone' confirm on chapter delete and the inaccurate 'permanently removes' wording on book delete. --- src/main/chapters.ts | 26 +++++++++++++ src/main/index.ts | 53 +++++++++++++++++++++++++ src/main/preload.ts | 3 ++ src/renderer/book.ts | 80 ++++++++++++++++++++++++++++++++------ src/renderer/index.css | 28 +++++++++++-- src/renderer/index.html | 4 ++ tests/selftest-scenario.js | 32 ++++++++++++++- 7 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/main/chapters.ts b/src/main/chapters.ts index b8d1eb6..ddf30b2 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -155,6 +155,32 @@ export function deleteChapter(bookPath: string, id: string): void { saveMeta(bookPath, meta); } +// Snapshot of a deleted chapter so it can be restored in place (Undo). +export interface ChapterSnapshot { + id: string; + title: string; + color?: string | null; + content: string; + index: number; +} + +// Re-create a previously deleted chapter at its original position, restoring +// its title, color, and content. The chapter is stored as Markdown so it reads +// correctly regardless of the format it originally used. +export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry { + const meta = loadBook(bookPath); + const file = path.posix.join("chapters", chapterFileName(snap.id)); + fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true }); + fs.writeFileSync(path.join(bookPath, file), snap.content, "utf-8"); + const entry: ChapterEntry = { id: snap.id, title: snap.title, file }; + if (snap.color) entry.color = snap.color; + meta.chapters[snap.id] = entry; + const idx = Math.max(0, Math.min(snap.index, meta.chapterOrder.length)); + meta.chapterOrder.splice(idx, 0, snap.id); + saveMeta(bookPath, meta); + return entry; +} + export function duplicateChapter( bookPath: string, id: string, diff --git a/src/main/index.ts b/src/main/index.ts index faa86ec..707f60c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import { reorderChapters, listChaptersInOrder, setChapterContent, + restoreChapter, } from "./chapters.js"; import { combineMarkdown, buildZip } from "./export.js"; import { buildDocx, buildOdt } from "./office-export.js"; @@ -309,6 +310,58 @@ ipcMain.handle("folio:deleteChapter", (_e, id: string) => { } }); +ipcMain.handle("folio:restoreChapter", (_e, snap: unknown) => { + try { + restoreChapter(requireBook(), snap as never); + notifyChange(); + return { ok: true }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// Restore a book the user just trashed (Undo for book deletion). The desktop +// trash follows the XDG spec on Linux; we match the original path in the +// .trashinfo and move the item back. +function restoreBookFromTrash(p: string): { ok?: boolean; error?: string } { + try { + const home = app.getPath("home"); + const infoDir = path.join(home, ".local", "share", "Trash", "info"); + const filesDir = path.join(home, ".local", "share", "Trash", "files"); + if (!fs.existsSync(infoDir)) return { error: "Trash not available." }; + for (const infoFile of fs.readdirSync(infoDir)) { + if (!infoFile.endsWith(".trashinfo")) continue; + const txt = fs.readFileSync(path.join(infoDir, infoFile), "utf-8"); + const m = /^Path=(.*)$/m.exec(txt); + if (!m) continue; + let orig = decodeURIComponent(m[1].trim()); + if (!path.isAbsolute(orig)) orig = path.join(home, orig); + if (orig !== p) continue; + const base = infoFile.slice(0, -".trashinfo".length); + const trashed = path.join(filesDir, base); + if (!fs.existsSync(trashed)) return { error: "Trashed book missing." }; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.renameSync(trashed, p); + fs.rmSync(path.join(infoDir, infoFile), { force: true }); + try { + if (process.env.FOLIO_SELF_TEST !== "1") { + const meta = loadBook(p); + addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() }); + } + } catch { + /* recents best-effort */ + } + refreshMenu(); + return { ok: true }; + } + return { error: "Book not found in trash." }; + } catch (e) { + return { error: (e as Error).message }; + } +} + +ipcMain.handle("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p)); + ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => { try { const entry = duplicateChapter(requireBook(), id, title); diff --git a/src/main/preload.ts b/src/main/preload.ts index 08c8f19..59b5adc 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", { // book / project newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name), deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p), + restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), @@ -24,6 +25,8 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.invoke("folio:setChapterColor", id, color), deleteChapter: (id: string) => ipcRenderer.invoke("folio:deleteChapter", id), + restoreChapter: (snap: unknown) => + ipcRenderer.invoke("folio:restoreChapter", snap), duplicateChapter: (id: string, title?: string) => ipcRenderer.invoke("folio:duplicateChapter", id, title), reorderChapters: (newOrder: string[]) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 858e98f..409c418 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -17,9 +17,18 @@ import { nativeNodeText, } from "./native-assembly.js"; +export interface ChapterSnapshot { + id: string; + title: string; + color?: string | null; + content: string; + index: number; +} + export type FolioAPI = { newBookNamed: (name: string) => Promise; deleteBook: (p: string) => Promise; + restoreBook: (p: string) => Promise; openBook: () => Promise; openBookPath: (p: string) => Promise; revealInFolder: (p: string) => Promise; @@ -33,6 +42,7 @@ export type FolioAPI = { renameChapter: (id: string, title: string) => Promise; setChapterColor: (id: string, color: string | null) => Promise; deleteChapter: (id: string) => Promise; + restoreChapter: (snap: ChapterSnapshot) => Promise; duplicateChapter: (id: string, title?: string) => Promise; reorderChapters: (newOrder: string[]) => Promise; saveChapter: (id: string, content: string) => Promise; @@ -937,6 +947,34 @@ export function initBook(api: FolioAPI) { }, 1400); } + // Toast with an Undo action, used after destructive operations (delete book, + // delete/cut chapter). Auto-dismisses; the action runs once if tapped. + let undoToastTimer: ReturnType | null = null; + function hideUndoToast(): void { + const el = $("undoToast"); + if (!el) return; + el.classList.remove("show"); + setTimeout(() => el.classList.add("hidden"), 200); + } + function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void { + const el = $("undoToast"); + if (!el) { + onUndo(); + return; + } + const msgEl = $("undoToastMsg"); + const btn = $("undoToastBtn") as HTMLButtonElement | null; + if (msgEl) msgEl.textContent = msg; + if (btn) btn.onclick = () => { + hideUndoToast(); + onUndo(); + }; + el.classList.remove("hidden"); + el.classList.add("show"); + if (undoToastTimer) clearTimeout(undoToastTimer); + undoToastTimer = setTimeout(hideUndoToast, timeout); + } + async function saveCurrentChapter(): Promise { if (!selectedId) return; const res = await api.saveChapterDoc(selectedId, getEditorDoc(editor)); @@ -1285,14 +1323,31 @@ export function initBook(api: FolioAPI) { async function del(id: string) { if (!meta || !meta.chapters[id]) return; const entry = meta.chapters[id]; - const confirmed = await confirmMessage(`Delete "${entry.title}"?`, "This cannot be undone."); - if (!confirmed) return; + const index = meta.chapterOrder.indexOf(id); + const content = (await api.getChapterContent(id)) as string; const res = await api.deleteChapter(id); - if (res && "error" in res) await alertMessage("Could not delete chapter", (res as { error: string }).error); - else { - if (selectedId === id) { selectedId = null; showPlaceholder(); } - await refreshMeta(); + if (res && "error" in res) { + await alertMessage("Could not delete chapter", (res as { error: string }).error); + return; } + const wasSelected = selectedId === id; + if (wasSelected) { selectedId = null; showPlaceholder(); } + await refreshMeta(); + showUndoToast(`Deleted "${entry.title}"`, async () => { + const r = await api.restoreChapter({ + id, + title: entry.title, + color: entry.color ?? null, + content, + index, + }); + if (r && "error" in r) { + await alertMessage("Could not restore chapter", (r as { error: string }).error); + return; + } + await refreshMeta(); + if (wasSelected) await selectChapter(id); + }); } async function duplicate(id: string) { @@ -1394,17 +1449,20 @@ export function initBook(api: FolioAPI) { del.textContent = "×"; del.addEventListener("click", async (e) => { e.stopPropagation(); - const ok = await confirmMessage( - "Delete book", - `Delete "${r.title || r.path}"? This permanently removes the book folder.` - ); - if (!ok) return; const res = await api.deleteBook(r.path); if (res && "error" in res) { await alertMessage("Could not delete", (res as { error: string }).error); return; } await renderRecents(); + showUndoToast(`Deleted "${r.title || r.path}"`, async () => { + const rr = await api.restoreBook(r.path); + if (rr && "error" in rr) { + await alertMessage("Could not restore", (rr as { error: string }).error); + return; + } + await renderRecents(); + }); }); li.appendChild(label); li.appendChild(del); diff --git a/src/renderer/index.css b/src/renderer/index.css index bc8716e..74c3364 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1067,10 +1067,30 @@ body.dragging-active * { transition: opacity 0.18s ease, transform 0.18s ease; z-index: 1000; } -.toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0); -} + .toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + + .undo-toast { + display: flex; + align-items: center; + gap: 14px; + pointer-events: auto; + } + .undo-toast .toast-action { + background: rgba(255, 255, 255, 0.18); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 6px; + font: inherit; + font-weight: 700; + padding: 5px 14px; + cursor: pointer; + } + .undo-toast .toast-action:hover { + background: rgba(255, 255, 255, 0.3); + } /* ---- toolbar dropdowns ---- */ .toolbar-dropdown { diff --git a/src/renderer/index.html b/src/renderer/index.html index cb610e8..bc38736 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -273,6 +273,10 @@ +