diff --git a/src/main/index.ts b/src/main/index.ts index 8207f6c..fd42374 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, renameRecent, migrateLegacyRecents } from "./recents.js"; +import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -363,6 +363,20 @@ handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => { 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", () => { bookPath = null; mainWindow?.setTitle("Folio"); diff --git a/src/main/menu.ts b/src/main/menu.ts index 0bd9cb1..52c3951 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,6 +1,6 @@ import path from "path"; import { app, Menu, BrowserWindow } from "electron"; -import { listRecents } from "./recents.js"; +import { listVisibleRecents } from "./recents.js"; import type { Recent } from "./recents.js"; type MenuItem = Electron.MenuItemConstructorOptions; @@ -14,7 +14,7 @@ export function buildMenu( onRequestOpen: () => void ) { const isMac = process.platform === "darwin"; - const recents = listRecents(); + const recents = listVisibleRecents(); const recentItems: MenuItem[] = recents.length ? recents.map((r: Recent) => { @@ -63,11 +63,11 @@ export function buildMenu( accelerator: "CmdOrCtrl+R", // Always usable: falls back to the most recent valid book when none // 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: () => { const target = getState().bookPath ?? - listRecents().find((r) => !r.missing)?.path ?? + listVisibleRecents().find((r) => !r.missing)?.path ?? null; if (!target) return; getWindow()?.webContents.send("folio:reveal-requested", target); diff --git a/src/main/preload.ts b/src/main/preload.ts index df26b10..e926b6b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("folio", { 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), + hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), + unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p), 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 73dc9a5..4c310e9 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -37,6 +37,9 @@ export interface Recent { path: string; title: 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 { @@ -121,8 +124,10 @@ export function listRecents(filePath: string): 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); - recents.unshift(recent); + recents.unshift({ ...recent, hidden: false }); writeJsonFile(filePath, recents); return recents; } @@ -140,3 +145,22 @@ export function renameRecent(filePath: string, recentPath: string, title: string writeJsonFile(filePath, 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; +} diff --git a/src/main/recents.ts b/src/main/recents.ts index 50c6178..7f7a6f8 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -6,6 +6,8 @@ import { addRecent as _addRecent, removeRecent as _removeRecent, renameRecent as _renameRecent, + hideRecent as _hideRecent, + unhideRecent as _unhideRecent, } from "./project.js"; import { readJsonArray, writeJsonFile } from "./json-store.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 // 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. + // 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 // that happened to share a display name ("Untitled"), making one of them // unreachable from the recents menu. @@ -74,6 +78,12 @@ export function listRecents(): (Recent & { missing?: boolean })[] { 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[] { return _addRecent(recentsFilePath(), recent); } @@ -85,3 +95,11 @@ export function removeRecent(recentPath: string): Recent[] { export function renameRecent(recentPath: string, title: string): Recent[] { 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); +} diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 11aebda..95d1c00 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -23,6 +23,8 @@ export interface Recent { path: string; title: string; lastOpened: string; + hidden?: boolean; + missing?: boolean; } export interface OpenedBook { @@ -39,6 +41,8 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; renameBook: (p: string, title: string) => Promise; + hideRecent: (p: string) => Promise; + unhideRecent: (p: string) => Promise; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index d2543b6..4939609 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -185,91 +185,155 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { try { const recents = await api.getRecents(); 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"); li.className = "muted"; li.textContent = "No recent books."; recentList.appendChild(li); return; } - 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; - // 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); + for (const r of visible) { + recentList.appendChild(recentRow(r, false)); + } + if (hidden.length) { + recentList.appendChild(hiddenDisclosure(hidden)); } } catch { // 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 // row's action buttons (see .recent-actions CSS). function deselectRecent(li: HTMLElement) { diff --git a/src/renderer/index.css b/src/renderer/index.css index ad9509d..54d9612 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -665,6 +665,50 @@ html, body { 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 { color: var(--color-muted); } diff --git a/tests/run-project-test.mjs b/tests/run-project-test.mjs index 1ff5a34..67fba72 100644 --- a/tests/run-project-test.mjs +++ b/tests/run-project-test.mjs @@ -20,6 +20,8 @@ const { hasBook, listRecents, addRecent, + hideRecent, + unhideRecent, makeChapterId, chapterFileName, } = 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" }); 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"); ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id); ok(chapterFileName(id) === `${id}.md`, "filename is .md");