feat(recents): Hide action on recents rows — list-only, never touches the book on disk; 'Hidden (n)' disclosure to unhide; re-opening a book un-hides it; hidden entries dropped from Open Recent menu

This commit is contained in:
avi 2026-10-02 14:30:04 -05:00
commit 3e6962eda9
9 changed files with 264 additions and 79 deletions

View file

@ -17,7 +17,7 @@ import {
saveMeta, saveMeta,
Recent, Recent,
} from "./project.js"; } from "./project.js";
import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js"; import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js";
import { buildMenu } from "./menu.js"; import { buildMenu } from "./menu.js";
import { import {
createChapter, createChapter,
@ -363,6 +363,20 @@ handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => {
return { ok: true, title }; return { ok: true, title };
}); });
// Hide/unhide a recents entry. List-only: the book folder on disk is never
// touched, and opening the book again un-hides it (addRecent clears the flag).
handleIpc("folio:hideRecent", (_e, p: string) => {
hideRecent(p);
refreshMenu();
return { ok: true };
});
handleIpc("folio:unhideRecent", (_e, p: string) => {
unhideRecent(p);
refreshMenu();
return { ok: true };
});
handleIpc("folio:closeBook", () => { handleIpc("folio:closeBook", () => {
bookPath = null; bookPath = null;
mainWindow?.setTitle("Folio"); mainWindow?.setTitle("Folio");

View file

@ -1,6 +1,6 @@
import path from "path"; import path from "path";
import { app, Menu, BrowserWindow } from "electron"; import { app, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js"; import { listVisibleRecents } from "./recents.js";
import type { Recent } from "./recents.js"; import type { Recent } from "./recents.js";
type MenuItem = Electron.MenuItemConstructorOptions; type MenuItem = Electron.MenuItemConstructorOptions;
@ -14,7 +14,7 @@ export function buildMenu(
onRequestOpen: () => void onRequestOpen: () => void
) { ) {
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
const recents = listRecents(); const recents = listVisibleRecents();
const recentItems: MenuItem[] = recents.length const recentItems: MenuItem[] = recents.length
? recents.map((r: Recent) => { ? recents.map((r: Recent) => {
@ -63,11 +63,11 @@ export function buildMenu(
accelerator: "CmdOrCtrl+R", accelerator: "CmdOrCtrl+R",
// Always usable: falls back to the most recent valid book when none // Always usable: falls back to the most recent valid book when none
// is currently open, so the item never looks clickable yet do nothing. // is currently open, so the item never looks clickable yet do nothing.
enabled: !!getState().bookPath || listRecents().some((r) => !r.missing), enabled: !!getState().bookPath || listVisibleRecents().some((r) => !r.missing),
click: () => { click: () => {
const target = const target =
getState().bookPath ?? getState().bookPath ??
listRecents().find((r) => !r.missing)?.path ?? listVisibleRecents().find((r) => !r.missing)?.path ??
null; null;
if (!target) return; if (!target) return;
getWindow()?.webContents.send("folio:reveal-requested", target); getWindow()?.webContents.send("folio:reveal-requested", target);

View file

@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("folio", {
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), renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p),
unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p),
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

@ -37,6 +37,9 @@ export interface Recent {
path: string; path: string;
title: string; title: string;
lastOpened: string; lastOpened: string;
// Hidden entries stay in the recents file (and the book on disk is
// untouched) but are omitted from the visible recents list and menu.
hidden?: boolean;
} }
export interface BookResult { export interface BookResult {
@ -121,8 +124,10 @@ export function listRecents(filePath: string): Recent[] {
} }
export function addRecent(filePath: string, recent: Recent): Recent[] { export function addRecent(filePath: string, recent: Recent): Recent[] {
// Opening a book always surfaces it: a previously hidden entry is
// un-hidden here, so re-opening is the natural way back.
const recents = listRecents(filePath).filter((r) => r.path !== recent.path); const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
recents.unshift(recent); recents.unshift({ ...recent, hidden: false });
writeJsonFile(filePath, recents); writeJsonFile(filePath, recents);
return recents; return recents;
} }
@ -140,3 +145,22 @@ export function renameRecent(filePath: string, recentPath: string, title: string
writeJsonFile(filePath, recents); writeJsonFile(filePath, recents);
return recents; return recents;
} }
// Hide/unhide are list-only operations: the entry stays in the recents file
// and the book directory on disk is never touched. Hiding a path that is not
// in the list is a no-op (there is nothing to hide).
export function hideRecent(filePath: string, recentPath: string): Recent[] {
const recents = listRecents(filePath).map((r) =>
r.path === recentPath ? { ...r, hidden: true } : r
);
writeJsonFile(filePath, recents);
return recents;
}
export function unhideRecent(filePath: string, recentPath: string): Recent[] {
const recents = listRecents(filePath).map((r) =>
r.path === recentPath ? { ...r, hidden: false } : r
);
writeJsonFile(filePath, recents);
return recents;
}

View file

@ -6,6 +6,8 @@ import {
addRecent as _addRecent, addRecent as _addRecent,
removeRecent as _removeRecent, removeRecent as _removeRecent,
renameRecent as _renameRecent, renameRecent as _renameRecent,
hideRecent as _hideRecent,
unhideRecent as _unhideRecent,
} 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";
@ -60,6 +62,8 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
// Keep every saved entry so a book never "disappears" from the list just // Keep every saved entry so a book never "disappears" from the list just
// because its folder moved or was renamed. Mark entries whose book can't be // because its folder moved or was renamed. Mark entries whose book can't be
// found so the UI can show them as missing instead of hiding them. // found so the UI can show them as missing instead of hiding them.
// Hidden entries are returned too (flagged) — the UI decides visibility so
// it can offer a "Hidden (n)" disclosure to bring them back.
// Dedupe by PATH: keying on title used to hide genuinely distinct books // Dedupe by PATH: keying on title used to hide genuinely distinct books
// that happened to share a display name ("Untitled"), making one of them // that happened to share a display name ("Untitled"), making one of them
// unreachable from the recents menu. // unreachable from the recents menu.
@ -74,6 +78,12 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
return deduped; return deduped;
} }
// Entries shown in the Open Recent menu and the welcome Recents list:
// everything except explicitly hidden ones.
export function listVisibleRecents(): (Recent & { missing?: boolean })[] {
return listRecents().filter((r) => !r.hidden);
}
export function addRecent(recent: Recent): Recent[] { export function addRecent(recent: Recent): Recent[] {
return _addRecent(recentsFilePath(), recent); return _addRecent(recentsFilePath(), recent);
} }
@ -85,3 +95,11 @@ export function removeRecent(recentPath: string): Recent[] {
export function renameRecent(recentPath: string, title: string): Recent[] { export function renameRecent(recentPath: string, title: string): Recent[] {
return _renameRecent(recentsFilePath(), recentPath, title); return _renameRecent(recentsFilePath(), recentPath, title);
} }
export function hideRecent(recentPath: string): Recent[] {
return _hideRecent(recentsFilePath(), recentPath);
}
export function unhideRecent(recentPath: string): Recent[] {
return _unhideRecent(recentsFilePath(), recentPath);
}

View file

@ -23,6 +23,8 @@ export interface Recent {
path: string; path: string;
title: string; title: string;
lastOpened: string; lastOpened: string;
hidden?: boolean;
missing?: boolean;
} }
export interface OpenedBook { export interface OpenedBook {
@ -39,6 +41,8 @@ export type FolioAPI = {
revealInFolder: (p: string) => Promise<boolean>; revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>; getRecents: () => Promise<any[]>;
renameBook: (p: string, title: string) => Promise<any>; renameBook: (p: string, title: string) => Promise<any>;
hideRecent: (p: string) => Promise<any>;
unhideRecent: (p: 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>;

View file

@ -185,91 +185,155 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
try { try {
const recents = await api.getRecents(); const recents = await api.getRecents();
recentList.innerHTML = ""; recentList.innerHTML = "";
if (!recents.length) { const visible = (recents as (Recent & { missing?: boolean })[]).filter(
(r) => !r.hidden
);
const hidden = (recents as (Recent & { missing?: boolean })[]).filter(
(r) => r.hidden
);
if (!visible.length && !hidden.length) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "muted"; li.className = "muted";
li.textContent = "No recent books."; li.textContent = "No recent books.";
recentList.appendChild(li); recentList.appendChild(li);
return; return;
} }
for (const r of recents as (Recent & { missing?: boolean })[]) { for (const r of visible) {
const li = document.createElement("li"); recentList.appendChild(recentRow(r, false));
li.className = "recent-item"; }
li.tabIndex = 0; if (hidden.length) {
const label = document.createElement("span"); recentList.appendChild(hiddenDisclosure(hidden));
label.className = "recent-title";
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
label.title = 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-action icon-button recent-delete";
del.type = "button";
del.title = "Delete book";
del.setAttribute("aria-label", "Delete book");
del.textContent = "Delete";
del.addEventListener("click", async (e) => {
e.stopPropagation();
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();
// Only promise an in-app Undo where restore actually works (Linux
// home trash). Elsewhere the book sits in the OS trash, which the
// user can restore manually — no false "Undo" button.
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
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();
});
});
actions.appendChild(del);
li.appendChild(label);
li.appendChild(actions);
recentList.appendChild(li);
} }
} catch { } catch {
// Recents are best-effort; never block the welcome screen. // Recents are best-effort; never block the welcome screen.
} }
} }
function recentRow(r: Recent & { missing?: boolean }, isHidden: boolean): HTMLLIElement {
const li = document.createElement("li");
li.className = "recent-item" + (isHidden ? " recent-hidden-entry" : "");
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;
// Single click selects the row (reveals Rename/Hide/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);
}
if (isHidden) {
const unhide = document.createElement("button");
unhide.className = "recent-action icon-button recent-hide";
unhide.type = "button";
unhide.title = "Show in recents (book on disk is untouched)";
unhide.setAttribute("aria-label", "Unhide book in recents");
unhide.textContent = "Unhide";
unhide.addEventListener("click", async (e) => {
e.stopPropagation();
await api.unhideRecent(r.path);
await renderRecents();
});
actions.appendChild(unhide);
} else {
// Hide is list-only — safe by design, so it needs no confirmation and
// sits BEFORE the destructive Delete so a mis-click lands on the safe one.
const hide = document.createElement("button");
hide.className = "recent-action icon-button recent-hide";
hide.type = "button";
hide.title = "Hide from recents (book on disk is untouched)";
hide.setAttribute("aria-label", "Hide from recents");
hide.textContent = "Hide";
hide.addEventListener("click", async (e) => {
e.stopPropagation();
await api.hideRecent(r.path);
await renderRecents();
});
actions.appendChild(hide);
}
const del = document.createElement("button");
del.className = "recent-action icon-button recent-delete";
del.type = "button";
del.title = "Delete book";
del.setAttribute("aria-label", "Delete book");
del.textContent = "Delete";
del.addEventListener("click", async (e) => {
e.stopPropagation();
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();
// Only promise an in-app Undo where restore actually works (Linux
// home trash). Elsewhere the book sits in the OS trash, which the
// user can restore manually — no false "Undo" button.
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
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();
});
});
actions.appendChild(del);
li.appendChild(label);
li.appendChild(actions);
return li;
}
// Collapsible "Hidden (n)" row: click to expand the hidden entries inline,
// each offering Unhide. Hidden books stay reachable without polluting the
// main list.
function hiddenDisclosure(hidden: (Recent & { missing?: boolean })[]): HTMLLIElement {
const wrap = document.createElement("li");
wrap.className = "recent-hidden-group";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "recent-hidden-toggle";
toggle.textContent = `Hidden (${hidden.length})`;
const sub = document.createElement("ul");
sub.className = "recent-list recent-hidden-items hidden";
for (const r of hidden) sub.appendChild(recentRow(r, true));
toggle.addEventListener("click", () => {
sub.classList.toggle("hidden");
toggle.classList.toggle("open");
});
wrap.appendChild(toggle);
wrap.appendChild(sub);
return wrap;
}
// At most one recents row is selected at a time; selection reveals the // At most one recents row is selected at a time; selection reveals the
// row's action buttons (see .recent-actions CSS). // row's action buttons (see .recent-actions CSS).
function deselectRecent(li: HTMLElement) { function deselectRecent(li: HTMLElement) {

View file

@ -665,6 +665,50 @@ html, body {
color: #ff6b6b; color: #ff6b6b;
} }
/* Hide is list-only, so it reads quieter than the destructive Delete. */
.recent-item .recent-hide:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.22);
}
/* "Hidden (n)" disclosure at the bottom of the recents list. */
.recent-hidden-group {
list-style: none;
margin-top: 4px;
}
.recent-hidden-toggle {
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 0.72rem;
padding: 2px 6px;
border-radius: var(--radius);
}
.recent-hidden-toggle:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.12);
}
.recent-hidden-toggle::before {
content: "▸ ";
}
.recent-hidden-toggle.open::before {
content: "▾ ";
}
.recent-hidden-items {
margin-left: 6px;
}
.recent-hidden-items .recent-title {
opacity: 0.75;
}
.muted { .muted {
color: var(--color-muted); color: var(--color-muted);
} }

View file

@ -20,6 +20,8 @@ const {
hasBook, hasBook,
listRecents, listRecents,
addRecent, addRecent,
hideRecent,
unhideRecent,
makeChapterId, makeChapterId,
chapterFileName, chapterFileName,
} = mod; } = mod;
@ -65,6 +67,19 @@ ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top");
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" }); const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top"); ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
// Hide/unhide: list-only — entry stays in the file, disk untouched.
const h1 = hideRecent(rf, "/other");
ok(h1.find((r) => r.path === "/other")?.hidden === true, "hide marks entry hidden");
ok(h1.length === 2, "hide keeps the entry in the file");
ok(fs.existsSync(path.join(dir, "folio.json")), "hide leaves book on disk intact");
const h2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-04-04T00:00:00Z" });
ok(h2.find((r) => r.path === "/other")?.hidden === false, "re-opening un-hides");
const h3 = hideRecent(rf, "/other");
const h4 = unhideRecent(rf, "/other");
ok(h4.find((r) => r.path === "/other")?.hidden === false, "unhide clears the flag");
ok(h3.length === 2 && h4.length === 2, "hide/unhide never drop entries");
ok(hideRecent(rf, "/nowhere").length === 2, "hide of unknown path is a no-op");
const id = makeChapterId("Chapter One"); const id = makeChapterId("Chapter One");
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id); ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md"); ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");