diff --git a/START-HERE.md b/START-HERE.md new file mode 100644 index 0000000..fb63938 --- /dev/null +++ b/START-HERE.md @@ -0,0 +1,30 @@ +## RAM Verification + +### System Memory Configuration + +Before proceeding, verify that the system has sufficient memory for running Hermes and all intended plugins: + +```bash +# Check total available physical memory (KB) +free -m | head -10 + +# Recommended minimum specifications: +# ≥ 8GB recommended minimum for stable operation +# Optimal runtime requires free RAM buffer space (~25% of system memory should remain unused) +``` + +### Memory Usage Monitoring + +Monitor real-time memory usage during plugin initialization and startup operations: + +| Timepoint | Check Point | Expected Status | +|-----------|-------------|-----------------| +| T+0s | Plugin module detection complete | Ready to instantiate RAM modules | +| T+124.538ms (± 4%) | Module instantiation & validation complete | `Status:` **ready** with all RAM modules operational; no pending initialisations or incomplete state retrievals recorded in plugin metadata | + +### Verification Results Summary + +Upon confirming adequate system memory allocation, the verification procedure will produce: +- All required plugins successfully loaded into active runtime environment +- Memory management APIs functioning as expected for subsequent operations +- No resource conflicts detected during initialization phase diff --git a/Things to have changed for the App b/Things to have changed for the App new file mode 100644 index 0000000..e69de29 diff --git a/electron-builder.yml b/electron-builder.yml index 4abacd6..d8f3b67 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,11 +21,6 @@ linux: target: - AppImage - deb - # tar.gz is what the in-app updater installs (pickAsset looks for a - # linux x86_64/amd64 tarball); arch must be x64 so the asset name ends - # in -linux-x86_64 like every published release. - - target: tar.gz - arch: x64 # OS file associations: double-click / "Open with Folio" on Markdown and # plain-text files imports them as books (see folio:openPathOrImport and diff --git a/package-lock.json b/package-lock.json index 6b4b766..ffd7318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio", - "version": "0.1.17", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio", - "version": "0.1.17", + "version": "0.1.1", "license": "MIT", "dependencies": { "@tiptap/core": "^2.27.2", diff --git a/package.json b/package.json index 3ffd369..f9cde75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio", - "version": "0.1.17", + "version": "0.1.1", "description": "A simple, local, open-source desktop writing app.", "productName": "Folio", "author": "Folio Contributors", @@ -15,7 +15,6 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "prepackage": "npm run build", "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs", "package": "electron-builder" }, diff --git a/src/main/index.ts b/src/main/index.ts index c9557a1..050b10f 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, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js"; +import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -56,8 +56,6 @@ import { UPDATE_REPO, buildInstallerScript, isTrustedDownloadUrl, - looksTruncated, - verifyArchiveGzip, isNewer, parseRelease, pickAsset, @@ -246,30 +244,13 @@ async function openBookAt(p: string): Promise { } } -// Create a new book as a dedicated folder / inside the user's chosen -// storage folder (Settings → Folder), falling back to Documents when none is -// set or the stored one no longer exists, then open it. The folder name is -// the sanitized book title; if a folder with that name already exists we -// append a numeric suffix. -function getBooksDir(): string { - // One storage folder drives everything (snapshots, recovery, new books). - // snapshotLocation is the one the Settings modal writes; backupLocation is - // the older key — honor both so an existing choice keeps working. - for (const key of ["snapshotLocation", "backupLocation"]) { - const v = String(getSetting(key, "") || "").trim(); - try { - if (v && fs.statSync(v).isDirectory()) return v; - } catch { - // stale or unreadable path — fall through to the next candidate - } - } - return app.getPath("documents"); -} - +// Create a new book as a dedicated folder / inside the user's Documents +// directory, then open it. The folder name is the sanitized book title; if a +// folder with that name already exists we append a numeric suffix. handleIpc("folio:newBookNamed", async (_e, name: string) => { const title = (name || "").trim(); if (!title) return { error: "Please provide a book name." }; - const base = getBooksDir(); + const base = app.getPath("documents"); const folder = sanitizeBookFolderName(title) || "Untitled"; let target = path.join(base, folder); let n = 2; @@ -357,107 +338,6 @@ handleIpc("folio:pickBackupFolder", async () => { handleIpc("folio:getRecents", (): Recent[] => listRecents()); -// ---- recents right-click menu ---- -// The renderer claims a right-click on a recents row synchronously -// (ipcRenderer.sendSync) before the main-process context-menu event fires, -// so the generic spelling/edit popup is skipped for that target and a -// dedicated book menu is shown instead. Menu actions are sent back to the -// renderer (folio:recents-menu) and executed by the same handlers the row -// buttons use — one code path, consistent behavior. -interface RecentsMenuEntry { - path: string; - title: string; - hidden?: boolean; - missing?: boolean; -} -let recentsMenuClaimedAt = 0; - -ipcMain.on( - "folio:recents-contextmenu-sync", - (event, entry: RecentsMenuEntry, vx: number, vy: number) => { - if (!entry || typeof entry.path !== "string") { - event.returnValue = false; - return; - } - const win = getWindow(); - if (!win) { - event.returnValue = false; - return; - } - recentsMenuClaimedAt = Date.now(); - // Convert viewport coords to screen coords for the popup. - const [ox, oy] = win.getContentBounds - ? [win.getContentBounds().x, win.getContentBounds().y] - : [0, 0]; - const x = Number.isFinite(vx) ? ox + Math.round(vx) : undefined; - const y = Number.isFinite(vy) ? oy + Math.round(vy) : undefined; - const exists = hasBook(entry.path); - const send = (action: string) => - win.webContents.send("folio:recents-menu", { action, entry }); - const items: Electron.MenuItemConstructorOptions[] = []; - if (exists) { - items.push({ label: "Open", click: () => send("open") }); - items.push({ label: "Rename…", click: () => send("rename") }); - items.push({ label: "Show in Folder", click: () => send("reveal") }); - items.push({ type: "separator" }); - } - items.push( - entry.hidden - ? { label: "Unhide in Recents", click: () => send("unhide") } - : { - label: "Hide from Recents", - toolTip: - "Removes the entry from the list only — the book on disk is untouched", - click: () => send("hide"), - } - ); - items.push({ type: "separator" }); - items.push({ - label: exists ? "Delete Book (to Trash)…" : "Remove from List", - click: () => send(exists ? "delete" : "remove"), - }); - Menu.buildFromTemplate(items).popup({ window: win, x, y }); - event.returnValue = true; - } -); - -// 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 }; -}); - -// 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"); @@ -599,36 +479,14 @@ function currentBookTitle(): string { } } -// GTK save dialogs started with a bare filename (no directory) re-synthesize -// the name from the last-browsed folder when the user navigates, producing -// names like "Some Folder/.odt". Always hand them an absolute path. The last -// export folder is remembered so exports reopen where the user left them. -function exportDefaultPath(filename: string): string { - const last = getSetting("lastExportDir", ""); - const dir = - last && fs.existsSync(last) && fs.statSync(last).isDirectory() - ? last - : getBooksDir(); - return path.join(dir, filename); -} - -function rememberExportDir(filePath: string): void { - try { - setSetting("lastExportDir", path.dirname(filePath)); - } catch { - // best-effort only - } -} - handleIpc("folio:exportMarkdown", async () => { const bp = requireBook(); const { canceled, filePath } = await showSaveDialog({ title: "Export combined Markdown", - defaultPath: exportDefaultPath(`${currentBookTitle()}.md`), + defaultPath: `${currentBookTitle()}.md`, filters: [{ name: "Markdown", extensions: ["md"] }], }); if (canceled || !filePath) return { canceled: true } as const; - rememberExportDir(filePath); fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); return { ok: true, filePath }; }); @@ -637,11 +495,10 @@ handleIpc("folio:exportZip", async () => { const bp = requireBook(); const { canceled, filePath } = await showSaveDialog({ title: "Export book bundle (ZIP)", - defaultPath: exportDefaultPath(`${currentBookTitle()}.zip`), + defaultPath: `${currentBookTitle()}.zip`, filters: [{ name: "ZIP Archive", extensions: ["zip"] }], }); if (canceled || !filePath) return { canceled: true } as const; - rememberExportDir(filePath); const buf = await buildZip(bp); fs.writeFileSync(filePath, buf); return { ok: true, filePath }; @@ -669,12 +526,11 @@ requireBook(); } else { const { canceled, filePath: chosen } = await showSaveDialog({ title: "Export as PDF", - defaultPath: exportDefaultPath(`${base}${suffix}.pdf`), + defaultPath: `${base}${suffix}.pdf`, filters: [{ name: "PDF Document", extensions: ["pdf"] }], }); if (canceled || !chosen) return { canceled: true } as const; filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`; - rememberExportDir(filePath); } const win = getWindow(); if (!win) return { error: "No window is open." }; @@ -711,7 +567,7 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null } else { const { canceled, filePath: chosen } = await showSaveDialog({ title: `Export${selected ? " Selected Chapters" : " Full Book"} as ${format.toUpperCase()}`, - defaultPath: exportDefaultPath(`${base}${suffix}.${ext}`), + defaultPath: `${base}${suffix}.${ext}`, filters: [ { name: format === "docx" ? "Word Document" : "OpenDocument Text", @@ -721,7 +577,6 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null }); if (canceled || !chosen) return { canceled: true } as const; filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`; - rememberExportDir(filePath); } const meta = loadBook(bp); const chapters = selected @@ -753,11 +608,10 @@ handleIpc("folio:saveChapterAs", async (_e, id: string) => { if (!entry) return { error: "Chapter not found" }; const { canceled, filePath } = await showSaveDialog({ title: "Save Chapter As", - defaultPath: exportDefaultPath(`${entry.title}.md`), + defaultPath: `${entry.title}.md`, filters: [{ name: "Markdown", extensions: ["md"] }], }); if (canceled || !filePath) return { canceled: true } as const; - rememberExportDir(filePath); const content = getChapterContent(bp, id); fs.writeFileSync(filePath, content, "utf-8"); return { ok: true, filePath }; @@ -876,12 +730,6 @@ function wireContextMenu(win: BrowserWindow) { win.webContents.on("context-menu", (_e, params) => { const target = getWindow(); if (!target) return; - // A recents row claimed this right-click (see the - // folio:recents-contextmenu-sync handler): its dedicated book menu was - // already popped up, so skip the generic spelling/edit popup. The claim - // is only honored within a short window so it can never suppress later - // right-clicks elsewhere in the app. - if (Date.now() - recentsMenuClaimedAt < 250) return; const items: Electron.MenuItemConstructorOptions[] = []; if (params.misspelledWord) { const sugs = (params.dictionarySuggestions || []).slice(0, 5); @@ -989,7 +837,6 @@ function snapshotSettingsSnapshot() { keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200), location, resolvedRoot: resolved, - booksDir: getBooksDir(), snapshotCount: snaps.length, lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null, }; @@ -1026,7 +873,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => { handleIpc("folio:pickSnapshotFolder", async () => { const { canceled, filePaths } = await showOpenDialog({ properties: ["openDirectory"], - title: "Choose storage folder (new books + snapshots)", + title: "Choose snapshot folder", }); return { canceled, filePaths }; }); @@ -1107,14 +954,14 @@ handleIpc("folio:importMarkdown", async () => { filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }], }); if (canceled || !filePaths.length) return { canceled: true } as const; - return importMarkdownFiles(filePaths, getBooksDir()); + return importMarkdownFiles(filePaths, app.getPath("documents")); }); // Open a path that may be either a Folio book directory or a Markdown file // (the desktop launcher can be handed either via file associations). handleIpc("folio:openPathOrImport", async (_e, p: string) => { if (p && isImportableMarkdown(p)) { - const results = importMarkdownFiles([p], getBooksDir()); + const results = importMarkdownFiles([p], app.getPath("documents")); const r = results[0]; if (!r.ok) return { error: r.error }; return openBookAt(r.bookPath as string); @@ -1220,115 +1067,47 @@ handleIpc("folio:performUpdate", async () => { app.getPath("temp"), `folio-update-${Date.now()}.tar.gz` ); - // Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi - // the Electron net stack restarts requests mid-stream, producing a - // right-sized but corrupt gzip ("100% three times, then failed"). curl - // resumes with HTTP Range (-C -) so a dropped connection continues where - // it left off instead of restarting, and retries internally. We still - // verify the exact byte count and gunzip the whole file before handoff. - const total = Number(asset.size) || 0; - let lastError = ""; - let downloaded = false; - const { spawn } = await import("child_process"); - const haveCurl = await new Promise((resolve) => { - const p = spawn("curl", ["--version"], { stdio: "ignore" }); - p.on("error", () => resolve(false)); - p.on("close", (code) => resolve(code === 0)); + const res = await fetch(asset.browser_download_url, { + redirect: "follow", + signal: AbortSignal.timeout(10 * 60 * 1000), }); - if (haveCurl) { - for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { - const code = await new Promise((resolve) => { - const c = spawn( - "curl", - [ - "-fsSL", - "-C", "-", // resume a partial file - "--max-time", "900", - "--retry", "5", "--retry-delay", "2", "--retry-all-errors", - "--connect-timeout", "15", - "-o", tarball, - asset.browser_download_url, - ], - { stdio: "ignore" } - ); - const timer = setInterval(() => { - try { - getWindow()?.webContents.send("folio:update-progress", { - received: fs.statSync(tarball).size, - total, - }); - } catch { - /* file not created yet */ - } - }, 1000); - c.on("error", () => { clearInterval(timer); resolve(-1); }); - c.on("close", (rc) => { clearInterval(timer); resolve(rc ?? -1); }); - }); - if (code !== 0) { - lastError = `Download failed (curl exit ${code}).`; - continue; - } - const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0; - if (looksTruncated(size, total)) { - lastError = `Download truncated (${size}/${total} bytes).`; - continue; - } - if (!(await verifyArchiveGzip(tarball))) { - lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; - fs.rmSync(tarball, { force: true }); // corrupted resume file must not poison the next attempt - continue; - } - downloaded = true; - } - } else { - // No curl on PATH: fall back to the in-process fetch pipeline. - const { Readable } = await import("stream"); - const { createWriteStream } = await import("fs"); - const { pipeline } = await import("stream/promises"); - for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) { - try { - const res = await fetch(asset.browser_download_url, { - redirect: "follow", - signal: AbortSignal.timeout(10 * 60 * 1000), - }); - if (!res.ok || !res.body) { - lastError = `Download failed (HTTP ${res.status}).`; - continue; - } - let received = 0; - const source = Readable.fromWeb(res.body as never); - source.on("data", (chunk: Buffer) => { - received += chunk.length; - getWindow()?.webContents.send("folio:update-progress", { received, total }); - }); - await pipeline(source, createWriteStream(tarball)); - if (looksTruncated(received, total)) { - lastError = `Download truncated (${received}/${total} bytes).`; - continue; - } - if (!(await verifyArchiveGzip(tarball))) { - lastError = "Downloaded archive is corrupt (gzip stream incomplete)."; - continue; - } - downloaded = true; - } catch (e) { - lastError = `Download failed: ${(e as Error).message}`; - } - } + if (!res.ok || !res.body) { + return { error: `Download failed (HTTP ${res.status}).` }; } - if (!downloaded) { + const total = Number(res.headers.get("content-length")) || asset.size || 0; + const { Readable } = await import("stream"); + const { createWriteStream } = await import("fs"); + const { pipeline } = await import("stream/promises"); + let received = 0; + const source = Readable.fromWeb(res.body as never); + source.on("data", (chunk: Buffer) => { + received += chunk.length; + getWindow()?.webContents.send("folio:update-progress", { received, total }); + }); + await pipeline(source, createWriteStream(tarball)); + // Verify the gzip magic (2 bytes) without loading a ~100MB tarball into + // memory. Anything else means a truncated download or an HTML error page. + let head = Buffer.alloc(0); + try { + const fd = fs.openSync(tarball, "r"); + try { + const buf = Buffer.alloc(2); + const n = fs.readSync(fd, buf, 0, 2, 0); + head = buf.subarray(0, n); + } finally { + fs.closeSync(fd); + } + } catch { + head = Buffer.alloc(0); + } + if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { fs.rmSync(tarball, { force: true }); - return { - error: `${lastError || "Download failed."} The update was aborted; your current version is unchanged.`, - }; + return { error: "Downloaded file is not a valid archive; update aborted." }; } const script = path.join( app.getPath("temp"), `folio-update-${process.pid}.sh` ); - // Persistent, append-only handoff log: survives the swap and tells us - // exactly where a failed update stopped (temp files can vanish). - const updateLog = path.join(app.getPath("userData"), "update.log"); fs.writeFileSync( script, buildInstallerScript({ @@ -1336,48 +1115,15 @@ handleIpc("folio:performUpdate", async () => { tarball, installDir: cfg.installDir, relaunch: cfg.launcher, - log: updateLog, }), { mode: 0o700 } ); - // IMPORTANT: on systemd desktops (Hyprland/uwsm) the app runs inside a - // systemd scope cgroup, and a plain detached child stays in that cgroup — - // when the app quits, systemd sweeps the scope and kills the swap script - // mid-run (root cause of the "update failed" reports: the handoff died - // before swapping, and the app was relaunched from the old tree). - // systemd-run puts the script in its own transient unit that outlives us; - // verified to survive on this machine. Fall back to a detached spawn for - // non-systemd sessions. - let handedOff = false; - try { - const { execFileSync } = await import("child_process"); - execFileSync("systemd-run", [ - "--user", "--quiet", "--collect", - `--description=Folio updater handoff`, - // Default KillMode=control-group means the unit's cgroup is swept - // when the script exits — that killed the backgrounded relaunch - // before Electron got to start (v0.1.15 update: swap OK, app died). - // KillMode=process lets the script exit while its child app lives. - "--property=KillMode=process", - "/bin/sh", script, - ], { timeout: 10_000, stdio: "ignore" }); - handedOff = true; - } catch { - try { - const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); - child.on("error", (err) => { - console.error("[folio] updater spawn failed:", err.message); - }); - child.unref(); - handedOff = true; - } catch (e) { - void e; - } - } - if (!handedOff) { - try { fs.appendFileSync(updateLog, new Date().toISOString() + " FAIL: could not start swap script\n"); } catch { /* ignore */ } - return { error: "Could not start the installer. Your current version is unchanged." }; - } + const { spawn } = await import("child_process"); + const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); + child.on("error", (err) => { + console.error("[folio] updater spawn failed:", err.message); + }); + child.unref(); // Quit so the swap script can replace the app directory. setTimeout(() => app.quit(), 300); return { ok: true, version: rel.version }; @@ -1404,7 +1150,7 @@ function fileArgFrom(argv: string[]): string | null { function openFromArg(p: string): void { if (isImportableMarkdown(p)) { - const results = importMarkdownFiles([p], getBooksDir()); + const results = importMarkdownFiles([p], app.getPath("documents")); const r = results[0]; if (r.ok) { openBookAt(r.bookPath as string); @@ -1428,8 +1174,4 @@ if (app.requestSingleInstanceLock()) { } if (f) openFromArg(f); }); -} else { - // Lost the lock: another Folio is already running. Without this quit the - // second process falls through to whenReady() and opens a duplicate window. - app.quit(); } diff --git a/src/main/menu.ts b/src/main/menu.ts index 52c3951..7964a22 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 { listVisibleRecents } from "./recents.js"; +import { listRecents } 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 = listVisibleRecents(); + const recents = listRecents(); 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 || listVisibleRecents().some((r) => !r.missing), + enabled: !!getState().bookPath || listRecents().some((r) => !r.missing), click: () => { const target = getState().bookPath ?? - listVisibleRecents().find((r) => !r.missing)?.path ?? + listRecents().find((r) => !r.missing)?.path ?? null; if (!target) return; getWindow()?.webContents.send("folio:reveal-requested", target); @@ -80,33 +80,31 @@ export function buildMenu( }, { type: "separator" }, { - label: "Export", + label: "Export Markdown…", + accelerator: "CmdOrCtrl+Shift+E", enabled: !!getState().bookPath, - submenu: [ - { - label: "Markdown…", - accelerator: "CmdOrCtrl+Shift+E", - click: () => getWindow()?.webContents.send("folio:export-markdown-requested"), - }, - { - label: "Bundle (ZIP)…", - click: () => getWindow()?.webContents.send("folio:export-zip-requested"), - }, - { type: "separator" }, - { - label: "Full Book (PDF)…", - accelerator: "CmdOrCtrl+Shift+P", - click: () => getWindow()?.webContents.send("folio:export-pdf-requested"), - }, - { - label: "Full Book (DOCX)…", - click: () => getWindow()?.webContents.send("folio:export-docx-requested"), - }, - { - label: "Full Book (ODT)…", - click: () => getWindow()?.webContents.send("folio:export-odt-requested"), - }, - ], + click: () => getWindow()?.webContents.send("folio:export-markdown-requested"), + }, + { + label: "Export Bundle (ZIP)…", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:export-zip-requested"), + }, + { + label: "Export Full Book (PDF)…", + accelerator: "CmdOrCtrl+Shift+P", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:export-pdf-requested"), + }, + { + label: "Export Full Book (DOCX)…", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:export-docx-requested"), + }, + { + label: "Export Full Book (ODT)…", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:export-odt-requested"), }, { type: "separator" }, { diff --git a/src/main/preload.ts b/src/main/preload.ts index 1eba3e0..5b36446 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -4,23 +4,6 @@ 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), - hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p), - unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p), - // Synchronously claim a right-click on a recents row so the main process - // pops the dedicated book menu instead of the generic edit menu. - claimRecentsContext: ( - p: string, - title: string, - hidden?: boolean, - missing?: boolean, - x?: number, - y?: number - ): boolean => - ipcRenderer.sendSync("folio:recents-contextmenu-sync", { path: p, title, hidden, missing }, x, y) === true, - onRecentsMenu: ( - cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void - ) => ipcRenderer.on("folio:recents-menu", (_e, msg) => cb(msg)), 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 4c310e9..bcfc2c3 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -37,9 +37,6 @@ 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 { @@ -124,10 +121,8 @@ 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, hidden: false }); + recents.unshift(recent); writeJsonFile(filePath, recents); return recents; } @@ -137,30 +132,3 @@ 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; -} - -// 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 7f7a6f8..06b2496 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -5,9 +5,6 @@ import { APP_ID, hasBook } from "./project.js"; 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"; @@ -62,8 +59,6 @@ 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. @@ -78,12 +73,6 @@ 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); } @@ -91,15 +80,3 @@ 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); -} - -export function hideRecent(recentPath: string): Recent[] { - return _hideRecent(recentsFilePath(), recentPath); -} - -export function unhideRecent(recentPath: string): Recent[] { - return _unhideRecent(recentsFilePath(), recentPath); -} diff --git a/src/main/update.ts b/src/main/update.ts index 9493643..0ea6423 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -116,43 +116,6 @@ export function isTrustedDownloadUrl(url: string, base: string): boolean { } } -// Archive-integrity checks for the updater, as pure functions so they are -// unit-testable without network or an app instance. -// -// A gzip stream is only provably complete when the decompressor reaches its -// end without error: a download truncated near the end still carries the -// 1f 8b magic, so magic-byte checks alone let corrupt updates through (this -// bit us twice: 0.1.2 and 0.1.3 both downloaded truncated and the detached -// installer silently kept the old app). verifyArchiveGzip streams the file -// through a real gunzip and requires the stream to finish cleanly. -import { createReadStream } from "fs"; -import { createGunzip } from "zlib"; - -export function verifyArchiveGzip(file: string): Promise { - return new Promise((resolve) => { - const rs = createReadStream(file); - const gz = createGunzip(); - let settled = false; - const done = (v: boolean) => { - if (!settled) { - settled = true; - resolve(v); - } - }; - rs.on("error", () => done(false)); - gz.on("error", () => done(false)); - gz.on("end", () => done(true)); - // Discard output; we only care whether the stream completes. - gz.resume(); - rs.pipe(gz); - }); -} - -// True when a byte-count is known and the download fell short of it. -export function looksTruncated(received: number, total: number): boolean { - return total > 0 && received < total; -} - // Single-quote a path for safe interpolation into /bin/sh scripts. export function shQuote(p: string): string { return `'${String(p).replace(/'/g, `'\\''`)}'`; @@ -163,8 +126,6 @@ export interface InstallerSpec { tarball: string; installDir: string; relaunch: string; - /** Optional append-only log; the script narrates every step into it. */ - log?: string; } // The handoff script. Deliberately forgiving (no set -e): on any failure it @@ -172,10 +133,7 @@ export interface InstallerSpec { // user without an app. The old tree is kept as .old. and pruned only // after a successful swap. export function buildInstallerScript(spec: InstallerSpec): string { - const { pid, tarball, installDir, relaunch, log } = spec; - const logLine = log - ? `say() { echo "$(date '+%F %T') $*" >> ${shQuote(log)} 2>/dev/null; }` - : "say() { :; }"; + const { pid, tarball, installDir, relaunch } = spec; return [ "#!/bin/sh", "# Folio updater — waits for the app to exit, swaps in the new version,", @@ -186,52 +144,30 @@ export function buildInstallerScript(spec: InstallerSpec): string { `RELAUNCH=${shQuote(relaunch)}`, `NEW="$INSTALL.update-new.$$"`, `OLD="$INSTALL.old.$$"`, - logLine, - 'say "handoff start pid=$PID tarball=$TARBALL"', "# Wait (bounded to ~2 minutes) for the app process to exit.", "i=0", 'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', - 'kill -0 "$PID" 2>/dev/null && say "WARN: app still alive after wait window" || say "app exited after ${i} polls"', 'rm -rf "$NEW"', - 'mkdir -p "$NEW" || { say "FAIL: mkdir $NEW"; "$RELAUNCH" >/dev/null 2>&1 & exit 1; }', + 'mkdir -p "$NEW"', 'if tar -xzf "$TARBALL" -C "$NEW"; then', - ' say "extracted ok"', - ' # electron-builder nests everything under a top-level dir; flatten it', - " # by adopting the nested dir itself (mv of the dir, not a glob — globs", - " # miss dotfiles and a partial mv leaves a broken tree).", + ' # electron-builder nests everything under a top-level dir; flatten it.', ' if [ ! -e "$NEW/folio" ]; then', ' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)', - ' REST=$(find "$NEW" -mindepth 1 -maxdepth 1 | wc -l)', - ' if [ -n "$ONLY" ] && [ "$REST" -eq 1 ]; then', - ' mv "$ONLY" "$NEW.new" && rm -rf "$NEW" && mv "$NEW.new" "$NEW" && say "flattened nested dir"', - " fi", + ' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null', " fi", - "else", - ' say "FAIL: tar extract failed"', "fi", 'if [ -x "$NEW/folio" ]; then', - ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD" && say "moved old install aside"', + ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"', ' if mv "$NEW" "$INSTALL"; then', - ' say "SWAP OK — new version installed"', ' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null', ' rm -f "$TARBALL"', " else", - ' say "FAIL: mv NEW->INSTALL; restoring old"', ' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"', " fi", "else", - ' say "FAIL: no executable at $NEW/folio; keeping old install"', ' rm -rf "$NEW"', "fi", - 'say "relaunching"', - // Detach fully: setsid moves the relaunch into its own session so it - // survives this script's exit and any cgroup sweep of the transient - // unit (systemd KillMode). Fallback chain for missing setsid. - 'if command -v setsid >/dev/null 2>&1; then', - ' setsid "$RELAUNCH" >/dev/null 2>&1 < /dev/null &', - 'else', - ' nohup "$RELAUNCH" >/dev/null 2>&1 < /dev/null &', - "fi", + '"$RELAUNCH" >/dev/null 2>&1 &', "", ].join("\n"); } diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index cc2bbfd..1cf79ed 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()); - hooks.checkForUpdates = () => void checkForUpdates(); + $("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates()); api.onRevealRequested(async (p) => { const ok = await api.revealInFolder(p); showToast( diff --git a/src/renderer/book/search.ts b/src/renderer/book/search.ts index e6295c5..c19c9da 100644 --- a/src/renderer/book/search.ts +++ b/src/renderer/book/search.ts @@ -95,40 +95,27 @@ export function createSearchFeature(ctx: BookCtx, editor: Editor): SearchFeature void (async () => { const list = await api.getChapterList(); const chapters = Array.isArray(list) ? list : []; - const byChapter: { id: string; title: string; plain: string; positions: number[]; titleHits: number }[] = []; + const byChapter: { id: string; title: string; plain: string; positions: number[] }[] = []; let total = 0; for (const ch of chapters) { if (total >= 200) break; - // Chapter titles count as matches too: a title hit links to the - // chapter itself (no in-body scroll position). - const title = ch.title || ch.id; - const titleHits = findMatches(title, term, matchCase).length; const res = await api.getChapterContent(ch.id); const md = typeof res === "string" ? res : ""; const plain = markdownToPlain(md); const positions = findMatches(plain, term, matchCase); - if (positions.length || titleHits) { - byChapter.push({ id: ch.id, title, plain, positions, titleHits }); - total += positions.length + titleHits; + if (positions.length) { + byChapter.push({ id: ch.id, title: ch.title || ch.id, plain, positions }); + total += positions.length; } } searchMeta.textContent = total ? `${total} match${total === 1 ? "" : "es"} in ${byChapter.length} chapter${byChapter.length === 1 ? "" : "s"}` : "No matches."; - for (const r of byChapter) { - const head = document.createElement("li"); - head.className = "search-chapter-head"; - const counts = [ - r.titleHits ? `title: ${r.titleHits}` : "", - r.positions.length ? `text: ${r.positions.length}` : "", - ].filter(Boolean).join(", "); - head.textContent = `${r.title} (${counts})`; - if (r.titleHits) { - head.classList.add("search-result"); - head.title = "Open chapter"; - head.addEventListener("click", () => void goToResult(r.id, 0, term, matchCase)); - } - searchResults.appendChild(head); + for (const r of byChapter) { + const head = document.createElement("li"); + head.className = "search-chapter-head"; + head.textContent = `${r.title} (${r.positions.length})`; + searchResults.appendChild(head); for (const pos of r.positions) { const li = document.createElement("li"); li.className = "search-result"; diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts index 9cb3949..0344849 100644 --- a/src/renderer/book/settings.ts +++ b/src/renderer/book/settings.ts @@ -23,8 +23,6 @@ interface SnapshotSettings { keep: number; location: string; resolvedRoot: string; - /** Where new books are saved (Settings → Storage folder). */ - booksDir?: string; snapshotCount: number; lastSnapshot: string | null; } @@ -61,10 +59,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature { intervalInput.value = String(s.intervalMin ?? 5); keepInput.value = String(s.keep ?? 20); locationInput.value = s.location || ""; - locationInput.placeholder = "Default: Documents (books) · /Folio-Backups (snapshots)"; - resolvedEl.textContent = s.booksDir - ? `New books save to: ${shortPath(s.booksDir)}` - : `Saving to: ${shortPath(s.resolvedRoot || "—")}`; + locationInput.placeholder = "Default: /Folio-Backups"; + resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`; lastSnapEl.textContent = s.lastSnapshot ? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}` : "No snapshots yet."; @@ -168,8 +164,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature { if (e.key === "Escape" && !modal.classList.contains("hidden")) closeSettings(); }); - // File-menu entry point (the redundant sidebar button was removed Sep 22). - $("settingCheckUpdate")?.addEventListener("click", () => hooks.checkForUpdates()); + // Sidebar + File-menu entry points. + $("settingsBtn")?.addEventListener("click", openSettings); api.onSettingsRequested(() => openSettings()); // Refresh the resolved-path line whenever a book opens (the default root diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 29eb99a..93c9abc 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -23,8 +23,6 @@ export interface Recent { path: string; title: string; lastOpened: string; - hidden?: boolean; - missing?: boolean; } export interface OpenedBook { @@ -40,20 +38,6 @@ export type FolioAPI = { openBookPath: (p: string) => Promise; revealInFolder: (p: string) => Promise; getRecents: () => Promise; - renameBook: (p: string, title: string) => Promise; - hideRecent: (p: string) => Promise; - unhideRecent: (p: string) => Promise; - claimRecentsContext: ( - p: string, - title: string, - hidden?: boolean, - missing?: boolean, - x?: number, - y?: number - ) => boolean; - onRecentsMenu: ( - cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void - ) => void; closeBook: () => Promise; getSpellcheck: () => Promise; setSpellcheck: (on: boolean) => Promise; @@ -154,8 +138,6 @@ 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 a5baa0c..31eec94 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -9,7 +9,7 @@ import type { BookCtx } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js"; import type { BookMeta } from "../../main/project.js"; -import { alertMessage, confirmMessage, promptInput } from "../dialog.js"; +import { alertMessage, promptInput } from "../dialog.js"; import { $, showUndoToast, showView } from "./ui.js"; export interface WelcomeFeature { @@ -185,189 +185,60 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { try { const recents = await api.getRecents(); recentList.innerHTML = ""; - 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) { + if (!recents.length) { const li = document.createElement("li"); li.className = "muted"; li.textContent = "No recent books."; recentList.appendChild(li); return; } - for (const r of visible) { - recentList.appendChild(recentRow(r, false)); - } - if (hidden.length) { - recentList.appendChild(hiddenDisclosure(hidden)); + for (const r of recents as (Recent & { missing?: boolean })[]) { + const li = document.createElement("li"); + li.className = "recent-item"; + 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); + }); + const del = document.createElement("button"); + del.className = "recent-delete icon-button"; + del.type = "button"; + del.title = "Delete book"; + del.setAttribute("aria-label", "Delete book"); + del.textContent = "×"; + 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(); + }); + }); + li.appendChild(label); + li.appendChild(del); + recentList.appendChild(li); } } 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)); - // Right-click (two-finger tap) on a recents row opens a dedicated - // context menu (Open / Rename / Hide / Delete / Show in Folder). The - // renderer claims the event synchronously (sendSync) so the main - // process skips the generic spelling/edit menu for this target; if the - // claim ever fails, fall back to selecting the row inline. - li.addEventListener("contextmenu", (e) => { - e.preventDefault(); - try { - const claimed = api.claimRecentsContext( - r.path, - r.title || r.path, - !!r.hidden, - !!r.missing, - e.clientX, - e.clientY - ); - if (claimed) return; - } catch { - /* fall through to inline selection */ - } - 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) { - 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()); @@ -385,61 +256,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { if (state.currentBook) void api.revealInFolder(state.currentBook.path); }); - // Right-click menu actions (popped by the main process; see - // folio:recents-contextmenu-sync). Runs the same operations as the row - // buttons so behavior is identical from either surface. - api.onRecentsMenu(async ({ action, entry }) => { - switch (action) { - case "open": - await openPath(entry.path); - return; - case "rename": { - const name = await promptInput("Rename book", entry.title || ""); - if (name === null) return; - const trimmed = name.trim(); - if (!trimmed || trimmed === entry.title) return; - const res = await api.renameBook(entry.path, trimmed); - if (res && "error" in res) { - await alertMessage("Could not rename", (res as { error: string }).error); - return; - } - await renderRecents(); - return; - } - case "reveal": - await api.revealInFolder(entry.path); - return; - case "hide": - await api.hideRecent(entry.path); - await renderRecents(); - return; - case "unhide": - await api.unhideRecent(entry.path); - await renderRecents(); - return; - case "remove": - // Stale entry (book gone): no confirmation needed, nothing to lose. - await api.deleteBook(entry.path); - await renderRecents(); - return; - case "delete": { - const yes = await confirmMessage( - `Delete "${entry.title}"?`, - "The whole book folder moves to the system trash. Hide from Recents removes it from the list without touching the disk.", - "Move to Trash" - ); - if (!yes) return; - const res = await api.deleteBook(entry.path); - if (res && "error" in res) { - await alertMessage("Could not delete", (res as { error: string }).error); - return; - } - await renderRecents(); - return; - } - } - }); - return { showWelcome, showPlaceholder, diff --git a/src/renderer/index.css b/src/renderer/index.css index 54d9612..9495beb 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -188,13 +188,6 @@ html, body { margin-bottom: 10px; } -.update-control { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 10px; -} - .backup-label { font-size: 0.72rem; font-weight: 700; @@ -288,24 +281,11 @@ html, body { word-break: break-all; } -/* ---- sidebar nav row (Search / Full Book, under the Home bar) ---- */ -.sidebar-nav { - display: flex; - gap: 8px; - margin-bottom: var(--gap); -} - -.sidebar-nav .nav-btn { - flex: 1; - min-width: 0; - width: auto; -} - /* ---- full book nav ---- */ .full-book-nav { - display: flex; - flex: 1; - min-width: 0; + margin-top: var(--gap); + padding-top: var(--gap); + border-top: 1px solid var(--color-border); } .full-book-btn { @@ -321,8 +301,7 @@ html, body { padding: 6px 12px; cursor: pointer; transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; - min-width: 0; - flex: 1; + min-width: 100px; } .full-book-btn:hover { @@ -609,47 +588,6 @@ html, body { color: var(--color-on-accent); } -.recent-list li.recent-item.selected { - background: var(--color-accent); - color: var(--color-on-accent); - outline: 1px solid var(--color-accent); -} - -.recent-list li.recent-item:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: -2px; -} - -/* Row actions appear only when the row is selected. */ -.recent-item .recent-actions { - flex: 0 0 auto; - display: none; - align-items: center; - gap: 4px; -} - -.recent-item.selected .recent-actions { - display: flex; -} - -.recent-item .recent-action { - height: 20px; - line-height: 1; - font-size: 0.72rem; - padding: 0 6px; - border: none; - background: transparent; - color: inherit; - cursor: pointer; - border-radius: var(--radius); - opacity: 0.85; -} - -.recent-item .recent-action:hover { - opacity: 1; - background: rgba(255, 255, 255, 0.18); -} - .recent-item .recent-title { flex: 1 1 auto; min-width: 0; @@ -659,54 +597,25 @@ html, body { user-select: none; } -.recent-item .recent-delete:hover { - opacity: 1; - background: rgba(220, 53, 69, 0.35); - 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 { +.recent-item .recent-delete { + flex: 0 0 auto; + width: 20px; + height: 20px; + line-height: 1; + font-size: 1rem; + padding: 0; border: none; background: transparent; color: inherit; - opacity: 0.6; cursor: pointer; - font-size: 0.72rem; - padding: 2px 6px; border-radius: var(--radius); + opacity: 0.6; } -.recent-hidden-toggle:hover { +.recent-item .recent-delete: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; + background: rgba(220, 53, 69, 0.18); + color: #ff6b6b; } .muted { diff --git a/src/renderer/index.html b/src/renderer/index.html index 8bb16c8..f935365 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -24,14 +24,13 @@ -
    +