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.
This commit is contained in:
avi 2026-08-20 18:12:33 -05:00
commit aaa491424d
7 changed files with 210 additions and 16 deletions

View file

@ -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,

View file

@ -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);

View file

@ -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[]) =>

View file

@ -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<any>;
deleteBook: (p: string) => Promise<any>;
restoreBook: (p: string) => Promise<any>;
openBook: () => Promise<any>;
openBookPath: (p: string) => Promise<any>;
revealInFolder: (p: string) => Promise<boolean>;
@ -33,6 +42,7 @@ export type FolioAPI = {
renameChapter: (id: string, title: string) => Promise<any>;
setChapterColor: (id: string, color: string | null) => Promise<any>;
deleteChapter: (id: string) => Promise<any>;
restoreChapter: (snap: ChapterSnapshot) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
saveChapter: (id: string, content: string) => Promise<any>;
@ -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<typeof setTimeout> | 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<void> {
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);

View file

@ -1072,6 +1072,26 @@ body.dragging-active * {
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 {
position: relative;

View file

@ -273,6 +273,10 @@
</div>
</div>
<div class="toast hidden" id="toast"></div>
<div class="toast undo-toast hidden" id="undoToast" role="status" aria-live="polite">
<span id="undoToastMsg"></span>
<button type="button" class="toast-action" id="undoToastBtn">Undo</button>
</div>
<div class="modal-backdrop hidden" id="searchModal">
<div class="modal search-modal" role="dialog" aria-modal="true" aria-label="Search all chapters">
<h3 class="modal-title">Search</h3>

View file

@ -170,7 +170,33 @@
}
step("exports", ex);
// New book auto-creates a folder inside Documents; then delete it.
// Chapter delete + undo (snapshot restore).
try {
const before = (await api.getChapterList()).length;
const cc = await api.getChapterContent("ch-one");
const delc = await api.deleteChapter("ch-one");
const afterDel = (await api.getChapterList()).length;
const restc = await api.restoreChapter({
id: "ch-one",
title: "Book_The_Lab",
color: null,
content: cc,
index: 0,
});
const afterRest = (await api.getChapterList()).length;
const cc2 = await api.getChapterContent("ch-one");
step("chapterUndo", {
before,
afterDel,
afterRest,
restored: cc2 === cc,
ok: !!(delc && delc.ok) && !!(restc && restc.ok),
});
} catch (e) {
step("chapterUndoErr", String(e));
}
// New book auto-creates a folder inside Documents; delete then undo.
const nb = await api.newBookNamed("Folio Selftest Temp");
step("newBookNamed", {
ok: !!(nb && nb.path && nb.title),
@ -180,6 +206,10 @@
if (nb && nb.path) {
const del = await api.deleteBook(nb.path);
step("deleteBook", del && del.ok ? "ok" : del && del.error);
if (del && del.ok) {
const rest = await api.restoreBook(nb.path);
step("restoreBook", rest && rest.ok ? "ok" : rest && rest.error);
}
}
out.ok = true;