feat(books): rename book titles; click-to-select recents with Rename/Delete actions

This commit is contained in:
avi 2026-09-22 20:13:50 -05:00
commit 19255e07c8
7 changed files with 91 additions and 8 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@ export type FolioAPI = {
openBookPath: (p: string) => Promise<any>;
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
renameBook: (p: string, title: string) => Promise<any>;
closeBook: () => Promise<boolean>;
getSpellcheck: () => Promise<boolean>;
setSpellcheck: (on: boolean) => Promise<any>;
@ -138,6 +139,8 @@ export interface BookHooks {
isTypewriterEnabled(): boolean;
// settings feature
openSettings(): void;
// update flow (orchestrator)
checkForUpdates(): void;
// session coordinators (orchestrator)
refreshMeta(): Promise<void>;
showPlaceholder(): void;

View file

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