From 19255e07c815146b3da57f3809cafe8b770a62b0 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 22 Sep 2026 20:13:50 -0500 Subject: [PATCH] feat(books): rename book titles; click-to-select recents with Rename/Delete actions --- src/main/index.ts | 25 +++++++++++++++- src/main/preload.ts | 1 + src/main/project.ts | 8 ++++++ src/main/recents.ts | 5 ++++ src/renderer/book/index.ts | 2 +- src/renderer/book/types.ts | 3 ++ src/renderer/book/welcome.ts | 55 ++++++++++++++++++++++++++++++++---- 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 050b10f..169a3d4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { saveMeta, Recent, } from "./project.js"; -import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js"; +import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -338,6 +338,29 @@ handleIpc("folio:pickBackupFolder", async () => { handleIpc("folio:getRecents", (): Recent[] => listRecents()); +// Rename a book's display title (folio.json + matching recents entry). The +// folder on disk keeps its name: renaming the directory would invalidate +// every other recents/menu path that points at it, so the title is the only +// stable rename surface. +handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => { + const title = typeof rawTitle === "string" ? rawTitle.trim() : ""; + if (!title) return { error: "Book name cannot be empty." }; + if (!hasBook(p)) return { error: `Not a Folio book: ${p}` }; + try { + const meta = loadBook(p); + meta.title = title; + saveMeta(p, meta); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + renameRecent(p, title); + if (bookPath === p) { + mainWindow?.setTitle(`${title} — Folio`); + refreshMenu(); + } + return { ok: true, title }; +}); + handleIpc("folio:closeBook", () => { bookPath = null; mainWindow?.setTitle("Folio"); diff --git a/src/main/preload.ts b/src/main/preload.ts index 5b36446..df26b10 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), + renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p), openBook: () => ipcRenderer.invoke("folio:openBook"), openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), diff --git a/src/main/project.ts b/src/main/project.ts index bcfc2c3..73dc9a5 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -132,3 +132,11 @@ export function removeRecent(filePath: string, recentPath: string): Recent[] { writeJsonFile(filePath, recents); return recents; } + +export function renameRecent(filePath: string, recentPath: string, title: string): Recent[] { + const recents = listRecents(filePath).map((r) => + r.path === recentPath ? { ...r, title } : r + ); + writeJsonFile(filePath, recents); + return recents; +} diff --git a/src/main/recents.ts b/src/main/recents.ts index 06b2496..50c6178 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -5,6 +5,7 @@ import { APP_ID, hasBook } from "./project.js"; import { addRecent as _addRecent, removeRecent as _removeRecent, + renameRecent as _renameRecent, } from "./project.js"; import { readJsonArray, writeJsonFile } from "./json-store.js"; import type { Recent } from "./project.js"; @@ -80,3 +81,7 @@ export function addRecent(recent: Recent): Recent[] { export function removeRecent(recentPath: string): Recent[] { return _removeRecent(recentsFilePath(), recentPath); } + +export function renameRecent(recentPath: string, title: string): Recent[] { + return _renameRecent(recentsFilePath(), recentPath, title); +} diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 1cf79ed..cc2bbfd 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -150,7 +150,7 @@ export function initBook(api: FolioAPI) { api.onOpenRecent((p) => void welcome.openPath(p)); api.onOpenMarkdownRequested(() => void welcome.openMarkdown()); api.onCheckUpdateRequested(() => void checkForUpdates()); - $("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates()); + hooks.checkForUpdates = () => void checkForUpdates(); api.onRevealRequested(async (p) => { const ok = await api.revealInFolder(p); showToast( diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 93c9abc..11aebda 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -38,6 +38,7 @@ export type FolioAPI = { openBookPath: (p: string) => Promise; revealInFolder: (p: string) => Promise; getRecents: () => Promise; + renameBook: (p: string, title: string) => Promise; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; @@ -138,6 +139,8 @@ export interface BookHooks { isTypewriterEnabled(): boolean; // settings feature openSettings(): void; + // update flow (orchestrator) + checkForUpdates(): void; // session coordinators (orchestrator) refreshMeta(): Promise; showPlaceholder(): void; diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index 31eec94..d2543b6 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -195,20 +195,50 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { for (const r of recents as (Recent & { missing?: boolean })[]) { const li = document.createElement("li"); li.className = "recent-item"; + li.tabIndex = 0; const label = document.createElement("span"); label.className = "recent-title"; label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); label.title = r.path; - label.addEventListener("mousedown", (e) => { - e.preventDefault(); - void openPath(r.path); + // Single click selects the row (reveals Rename/Delete actions); + // double-click or Enter opens the book. Replaces the old always-on + // "×" button, which deleted on a mis-click. + label.addEventListener("click", () => selectRecent(li)); + li.addEventListener("dblclick", () => void openPath(r.path)); + li.addEventListener("keydown", (e) => { + if (e.key === "Enter") void openPath(r.path); + else if (e.key === "Escape" && li.classList.contains("selected")) deselectRecent(li); }); + const actions = document.createElement("span"); + actions.className = "recent-actions"; + if (!r.missing) { + const ren = document.createElement("button"); + ren.className = "recent-action icon-button"; + ren.type = "button"; + ren.title = "Rename book"; + ren.setAttribute("aria-label", "Rename book"); + ren.textContent = "✎"; + ren.addEventListener("click", async (e) => { + e.stopPropagation(); + const name = await promptInput("Rename book", r.title || ""); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed || trimmed === r.title) return; + const res = await api.renameBook(r.path, trimmed); + if (res && "error" in res) { + await alertMessage("Could not rename", (res as { error: string }).error); + return; + } + await renderRecents(); + }); + actions.appendChild(ren); + } const del = document.createElement("button"); - del.className = "recent-delete icon-button"; + del.className = "recent-action icon-button recent-delete"; del.type = "button"; del.title = "Delete book"; del.setAttribute("aria-label", "Delete book"); - del.textContent = "×"; + del.textContent = "Delete"; del.addEventListener("click", async (e) => { e.stopPropagation(); const res = await api.deleteBook(r.path); @@ -230,8 +260,9 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { await renderRecents(); }); }); + actions.appendChild(del); li.appendChild(label); - li.appendChild(del); + li.appendChild(actions); recentList.appendChild(li); } } catch { @@ -239,6 +270,18 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { } } + // At most one recents row is selected at a time; selection reveals the + // row's action buttons (see .recent-actions CSS). + function deselectRecent(li: HTMLElement) { + li.classList.remove("selected"); + } + function selectRecent(li: HTMLElement) { + recentList + .querySelectorAll("li.recent-item.selected") + .forEach((el) => el !== li && el.classList.remove("selected")); + li.classList.add("selected"); + } + // ---- wiring (bind once at construction) ---- $("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());