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, saveMeta,
Recent, Recent,
} from "./project.js"; } 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 { buildMenu } from "./menu.js";
import { import {
createChapter, createChapter,
@ -338,6 +338,29 @@ handleIpc("folio:pickBackupFolder", async () => {
handleIpc("folio:getRecents", (): Recent[] => listRecents()); 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", () => { handleIpc("folio:closeBook", () => {
bookPath = null; bookPath = null;
mainWindow?.setTitle("Folio"); mainWindow?.setTitle("Folio");

View file

@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld("folio", {
// book / project // book / project
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name), newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p), 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), restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
openBook: () => ipcRenderer.invoke("folio:openBook"), openBook: () => ipcRenderer.invoke("folio:openBook"),
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), 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); writeJsonFile(filePath, recents);
return 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 { import {
addRecent as _addRecent, addRecent as _addRecent,
removeRecent as _removeRecent, removeRecent as _removeRecent,
renameRecent as _renameRecent,
} from "./project.js"; } from "./project.js";
import { readJsonArray, writeJsonFile } from "./json-store.js"; import { readJsonArray, writeJsonFile } from "./json-store.js";
import type { Recent } from "./project.js"; import type { Recent } from "./project.js";
@ -80,3 +81,7 @@ export function addRecent(recent: Recent): Recent[] {
export function removeRecent(recentPath: string): Recent[] { export function removeRecent(recentPath: string): Recent[] {
return _removeRecent(recentsFilePath(), recentPath); 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.onOpenRecent((p) => void welcome.openPath(p));
api.onOpenMarkdownRequested(() => void welcome.openMarkdown()); api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
api.onCheckUpdateRequested(() => void checkForUpdates()); api.onCheckUpdateRequested(() => void checkForUpdates());
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates()); hooks.checkForUpdates = () => void checkForUpdates();
api.onRevealRequested(async (p) => { api.onRevealRequested(async (p) => {
const ok = await api.revealInFolder(p); const ok = await api.revealInFolder(p);
showToast( showToast(

View file

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

View file

@ -195,20 +195,50 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
for (const r of recents as (Recent & { missing?: boolean })[]) { for (const r of recents as (Recent & { missing?: boolean })[]) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "recent-item"; li.className = "recent-item";
li.tabIndex = 0;
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "recent-title"; label.className = "recent-title";
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : ""); label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
label.title = r.path; label.title = r.path;
label.addEventListener("mousedown", (e) => { // Single click selects the row (reveals Rename/Delete actions);
e.preventDefault(); // double-click or Enter opens the book. Replaces the old always-on
void openPath(r.path); // "×" 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"); const del = document.createElement("button");
del.className = "recent-delete icon-button"; del.className = "recent-action icon-button recent-delete";
del.type = "button"; del.type = "button";
del.title = "Delete book"; del.title = "Delete book";
del.setAttribute("aria-label", "Delete book"); del.setAttribute("aria-label", "Delete book");
del.textContent = "×"; del.textContent = "Delete";
del.addEventListener("click", async (e) => { del.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const res = await api.deleteBook(r.path); const res = await api.deleteBook(r.path);
@ -230,8 +260,9 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
await renderRecents(); await renderRecents();
}); });
}); });
actions.appendChild(del);
li.appendChild(label); li.appendChild(label);
li.appendChild(del); li.appendChild(actions);
recentList.appendChild(li); recentList.appendChild(li);
} }
} catch { } 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) ---- // ---- wiring (bind once at construction) ----
$("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());