feat: spellcheck menu, typewriter mode, file associations, updater hardening

English spellcheck suggestions with Add-to-dictionary in the native context menu plus a Settings toggle. Typewriter caret-centering mode. OS file associations for .md/.txt and Open File wording. Updater: release URL surfaced, single progress listener, two-byte tarball check, installer wait guard. Search/installer test suites registered; chapter-list layout polish; ignore KDE .directory files.
This commit is contained in:
avi 2026-09-19 16:38:17 -05:00
commit 542a545c0d
17 changed files with 445 additions and 73 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ dist/
*.asar *.asar
releases/ releases/
.DS_Store .DS_Store
.directory
tests/.tmp-folio-format-*.mjs tests/.tmp-folio-format-*.mjs
.opencode/ .opencode/

View file

@ -22,6 +22,25 @@ linux:
- AppImage - AppImage
- deb - deb
# OS file associations: double-click / "Open with Folio" on Markdown and
# plain-text files imports them as books (see folio:openPathOrImport and
# fileArgFrom in src/main/index.ts). Without this the desktop environment
# never offers Folio for .txt/.md files.
fileAssociations:
- ext:
- md
- markdown
name: Folio Markdown document
description: Markdown document opened as a Folio book
role: Editor
mimeType: text/markdown
- ext:
- txt
name: Folio text document
description: Plain-text document opened as a Folio book
role: Editor
mimeType: text/plain
# Debian package options live at the config root in electron-builder v26. # Debian package options live at the config root in electron-builder v26.
# Setting `depends` replaces the auto-generated list, so keep its entries and # Setting `depends` replaces the auto-generated list, so keep its entries and
# add what Electron links but the defaults omit — notably libasound2 (ALSA), # add what Electron links but the defaults omit — notably libasound2 (ALSA),

View file

@ -15,7 +15,7 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"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", "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" "package": "electron-builder"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -86,8 +86,8 @@ function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
height: 780, height: 780,
minWidth: 960, minWidth: 560,
minHeight: 640, minHeight: 400,
title: "Folio", title: "Folio",
icon: path.join(__dirname, "icons", "icon.png"), icon: path.join(__dirname, "icons", "icon.png"),
webPreferences: { webPreferences: {
@ -97,6 +97,7 @@ function createWindow() {
}, },
}); });
forwardEditShortcuts(mainWindow); forwardEditShortcuts(mainWindow);
wireContextMenu(mainWindow);
mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.webContents.on("did-finish-load", () => { mainWindow.webContents.on("did-finish-load", () => {
console.log("[folio] renderer loaded"); console.log("[folio] renderer loaded");
@ -127,6 +128,15 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
migrateLegacyRecents(); migrateLegacyRecents();
// English-only spellcheck dictionary + stored on/off preference.
try {
session.defaultSession.setSpellCheckerLanguages(["en-US"]);
if (getSetting<boolean>("spellcheckEnabled", true) === false) {
session.defaultSession.setSpellCheckerEnabled(false);
}
} catch (err) {
console.error("[folio] spellchecker setup failed:", (err as Error).message);
}
buildMenu( buildMenu(
getWindow, getWindow,
getState, getState,
@ -710,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => {
return win.isFullScreen(); return win.isFullScreen();
}); });
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste // Right-click menu with spelling suggestions (English-only dictionary).
// via the mouse work. The roles act on whatever element is focused. // Electron builds no default context menu, so without this the red
ipcMain.on("folio:context-menu", (event, x: number, y: number) => { // misspelling underlines had no way to reach a suggestion. Chromium reports
const win = BrowserWindow.fromWebContents(event.sender); // the misspelled word + candidates via the webContents event; suggestions
if (!win) return; // replace the word in place, and "Add to dictionary" persists to the
const menu = Menu.buildFromTemplate([ // session's custom words.
function wireContextMenu(win: BrowserWindow) {
win.webContents.on("context-menu", (_e, params) => {
const target = getWindow();
if (!target) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
const sugs = (params.dictionarySuggestions || []).slice(0, 5);
if (sugs.length) {
for (const s of sugs) {
items.push({ label: s, click: () => target.webContents.replaceMisspelling(s) });
}
} else {
items.push({ label: "(no suggestions)", enabled: false });
}
items.push({
label: "Add to dictionary",
click: () =>
target.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
});
items.push({ type: "separator" });
}
items.push(
{ role: "cut" }, { role: "cut" },
{ role: "copy" }, { role: "copy" },
{ role: "paste" }, { role: "paste" },
{ type: "separator" }, { type: "separator" },
{ role: "selectAll" }, { role: "selectAll" }
]); );
menu.popup({ window: win, x, y }); Menu.buildFromTemplate(items).popup({ window: target, x: params.x, y: params.y });
});
}
// Spellcheck preference (English-only dictionary). Applied at startup and
// toggled live from Settings; the editor's spellcheck attribute stays on so
// Chromium underlines misspellings whenever the session checker is enabled.
handleIpc("folio:getSpellcheck", () => getSetting<boolean>("spellcheckEnabled", true));
handleIpc("folio:setSpellcheck", (_e, on: unknown) => {
const enabled = on !== false;
setSetting("spellcheckEnabled", enabled);
try {
session.defaultSession.setSpellCheckerEnabled(enabled);
} catch {
// applied on next launch if the session is not ready yet
}
return { ok: true, enabled };
}); });
// Theme preference. Stored as a plain string so future themes can be added // Theme preference. Stored as a plain string so future themes can be added
@ -901,9 +949,9 @@ handleIpc("folio:importAttachment", async () => {
// never modified. Books land in Documents, same as "New". // never modified. Books land in Documents, same as "New".
handleIpc("folio:importMarkdown", async () => { handleIpc("folio:importMarkdown", async () => {
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
title: "Open Markdown file(s)", title: "Open Markdown or text file(s)",
properties: ["openFile", "multiSelections"], properties: ["openFile", "multiSelections"],
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }], filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
}); });
if (canceled || !filePaths.length) return { canceled: true } as const; if (canceled || !filePaths.length) return { canceled: true } as const;
return importMarkdownFiles(filePaths, app.getPath("documents")); return importMarkdownFiles(filePaths, app.getPath("documents"));
@ -956,6 +1004,7 @@ interface UpdateCheckResult {
updateAvailable?: boolean; updateAvailable?: boolean;
assetName?: string; assetName?: string;
notes?: string; notes?: string;
url?: string;
canInstall?: boolean; canInstall?: boolean;
error?: string; error?: string;
} }
@ -983,6 +1032,7 @@ handleIpc("folio:checkUpdate", async (): Promise<UpdateCheckResult> => {
updateAvailable: isNewer(rel.version, current), updateAvailable: isNewer(rel.version, current),
assetName: asset ? asset.name : undefined, assetName: asset ? asset.name : undefined,
notes: rel.notes, notes: rel.notes,
url: rel.url,
canInstall: canInstall:
!!asset && !!asset &&
isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) && isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) &&
@ -1035,7 +1085,21 @@ handleIpc("folio:performUpdate", async () => {
getWindow()?.webContents.send("folio:update-progress", { received, total }); getWindow()?.webContents.send("folio:update-progress", { received, total });
}); });
await pipeline(source, createWriteStream(tarball)); await pipeline(source, createWriteStream(tarball));
const head = fs.readFileSync(tarball).subarray(0, 2); // 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) { if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
fs.rmSync(tarball, { force: true }); fs.rmSync(tarball, { force: true });
return { error: "Downloaded file is not a valid archive; update aborted." }; return { error: "Downloaded file is not a valid archive; update aborted." };

View file

@ -1,8 +1,7 @@
import path from "path"; import path from "path";
import { app, dialog, Menu, BrowserWindow } from "electron"; import { app, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js"; import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js"; import type { Recent } from "./recents.js";
import { setRecoveryRoot } from "./backup.js";
type MenuItem = Electron.MenuItemConstructorOptions; type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null }; type GetState = () => { bookPath: string | null };
@ -39,7 +38,7 @@ export function buildMenu(
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ {
label: "Open Markdown File…", label: "Open File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"), click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
}, },
{ type: "separator" }, { type: "separator" },

View file

@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", {
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"), getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"), closeBook: () => ipcRenderer.invoke("folio:closeBook"),
showContextMenu: (x: number, y: number) =>
ipcRenderer.send("folio:context-menu", x, y), // spelling (English dictionary, suggestions in the native context menu)
getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"),
setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on),
// chapters // chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),

View file

@ -146,7 +146,7 @@ export function buildInstallerScript(spec: InstallerSpec): string {
`OLD="$INSTALL.old.$$"`, `OLD="$INSTALL.old.$$"`,
"# Wait (bounded to ~2 minutes) for the app process to exit.", "# Wait (bounded to ~2 minutes) for the app process to exit.",
"i=0", "i=0",
'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', 'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
'rm -rf "$NEW"', 'rm -rf "$NEW"',
'mkdir -p "$NEW"', 'mkdir -p "$NEW"',
'if tar -xzf "$TARBALL" -C "$NEW"; then', 'if tar -xzf "$TARBALL" -C "$NEW"; then',

View file

@ -151,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
li.draggable = false; li.draggable = false;
li.tabIndex = -1; li.tabIndex = -1;
li.innerHTML = ` li.innerHTML = `
<span class="drag-handle" title="Drag to reorder">⋮</span>
<span class="row-title" title="${ch.title}">${ch.title}</span> <span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions"> <span class="row-actions">
<button class="icon-btn" data-act="color" title="Chapter color">●</button> <button class="icon-btn" data-act="color" title="Chapter color">●</button>

View file

@ -51,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
}); });
const editorHost = $("editorHost"); const editorHost = $("editorHost");
// ---- typewriter mode ----
// Keeps the caret near 42% of the visible editor height by scrolling the
// ProseMirror surface (which is its own scroll container) as the selection
// moves. Renderer-local preference; nothing leaves the machine.
const TYPEWRITER_KEY = "folio:typewriter";
let typewriterOn = false;
try {
typewriterOn = localStorage.getItem(TYPEWRITER_KEY) === "1";
} catch {
typewriterOn = false;
}
function setTypewriterEnabled(on: boolean) {
typewriterOn = on !== false;
try {
localStorage.setItem(TYPEWRITER_KEY, typewriterOn ? "1" : "0");
} catch {
// private-mode storage failure: mode still applies for the session
}
if (typewriterOn) centerCaret();
}
function isTypewriterEnabled(): boolean {
return typewriterOn;
}
function centerCaret() {
if (!typewriterOn || !editor.isFocused) return;
if (!editor.state.selection.empty) return;
const scroller = editor.view.dom as HTMLElement;
const rect = scroller.getBoundingClientRect();
if (rect.height <= 0) return;
let y = 0;
try {
y = editor.view.coordsAtPos(editor.state.selection.from).top;
} catch {
return;
}
const dy = y - (rect.top + rect.height * 0.42);
if (Math.abs(dy) > 2) scroller.scrollTop += dy;
}
// The ProseMirror surface is narrower than its host for readable line // The ProseMirror surface is narrower than its host for readable line
// lengths. Keep the surrounding editor area clickable so a new chapter can // lengths. Keep the surrounding editor area clickable so a new chapter can
// be focused without requiring a click on the centered content column. // be focused without requiring a click on the centered content column.
@ -239,6 +278,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
setActive("align-left", fmt.align === "left" || fmt.align === ""); setActive("align-left", fmt.align === "left" || fmt.align === "");
setActive("align-center", fmt.align === "center"); setActive("align-center", fmt.align === "center");
setActive("align-right", fmt.align === "right"); setActive("align-right", fmt.align === "right");
// Defer to the next frame so coords reflect the updated layout.
if (typewriterOn) requestAnimationFrame(centerCaret);
} }
// Ctrl/Cmd+S saves the current chapter (in addition to the menu item). // Ctrl/Cmd+S saves the current chapter (in addition to the menu item).

View file

@ -144,6 +144,7 @@ export function initBook(api: FolioAPI) {
// server, then offer a one-click install. The app downloads the tarball, // server, then offer a one-click install. The app downloads the tarball,
// hands off to a detached swap script, and relaunches itself — no manual // hands off to a detached swap script, and relaunches itself — no manual
// reinstall. See src/main/update.ts for the safety properties. // reinstall. See src/main/update.ts for the safety properties.
let updateProgressWired = false;
async function checkForUpdates() { async function checkForUpdates() {
showToast("Checking for updates…"); showToast("Checking for updates…");
const res = await api.checkUpdate(); const res = await api.checkUpdate();
@ -164,18 +165,23 @@ export function initBook(api: FolioAPI) {
if (!res.canInstall) { if (!res.canInstall) {
await alertMessage( await alertMessage(
"Automatic install unavailable", "Automatic install unavailable",
"This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page." "This release has no installable Linux archive, or this Folio is not the user-local install." +
(res.url ? ` Download it manually from ${res.url}` : " Download it manually from the releases page.")
); );
return; return;
} }
// Registered once: checkForUpdates can run repeatedly per session and
// ipcRenderer.on would otherwise stack duplicate progress listeners.
if (!updateProgressWired) {
updateProgressWired = true;
let lastPct = -1; let lastPct = -1;
api.onUpdateProgress(({ received, total }) => { api.onUpdateProgress(({ received, total }) => {
const pct = total ? Math.floor((received / total) * 100) : -1; const pct = total ? Math.floor((received / total) * 100) : -1;
if (pct !== lastPct) { if (pct === lastPct) return;
lastPct = pct; lastPct = pct;
showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`); showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`);
}
}); });
}
const up = await api.performUpdate(); const up = await api.performUpdate();
if (up && up.error) { if (up && up.error) {
await alertMessage("Update failed", up.error); await alertMessage("Update failed", up.error);

View file

@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
// any of those closes the dropdowns. // any of those closes the dropdowns.
window.addEventListener("scroll", closeDropdowns, true); window.addEventListener("scroll", closeDropdowns, true);
window.addEventListener("resize", closeDropdowns); window.addEventListener("resize", closeDropdowns);
// Electron has no default right-click menu, so show one with Cut/Copy/Paste. // Note: right-click is handled by the main process (webContents
window.addEventListener("contextmenu", (e) => { // context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM
// Let Chromium show its native spelling suggestions when the target is a // contextmenu listener here — preventing the default would suppress the
// misspelled word. The custom menu is used for the rest of the editor. // native suggestion menu for misspelled words.
const target = e.target as HTMLElement | null;
if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) {
return;
}
e.preventDefault();
api.showContextMenu(e.clientX, e.clientY);
});
const headingSelect = $("headingSelect") as HTMLSelectElement; const headingSelect = $("headingSelect") as HTMLSelectElement;
headingSelect.addEventListener("change", () => { headingSelect.addEventListener("change", () => {

View file

@ -39,7 +39,8 @@ export type FolioAPI = {
revealInFolder: (p: string) => Promise<boolean>; revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>; getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>; closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void; getSpellcheck: () => Promise<boolean>;
setSpellcheck: (on: boolean) => Promise<any>;
getBookMeta: () => Promise<BookMeta | { error: string }>; getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>; setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>; getChapterList: () => Promise<any[]>;

View file

@ -44,23 +44,29 @@ html, body {
color: var(--color-text); color: var(--color-text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow: hidden;
max-width: 100vw;
} }
#root { #root {
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw;
max-width: 100vw;
overflow: hidden; overflow: hidden;
} }
.sidebar { .sidebar {
flex: 0 0 280px; flex: 0 0 280px;
min-width: 240px; min-width: 240px;
max-width: 360px; max-width: min(360px, 85vw);
flex-shrink: 0;
background: var(--color-panel); background: var(--color-panel);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap); padding: var(--gap);
overflow-x: clip;
} }
.sidebar-header { .sidebar-header {
@ -342,8 +348,10 @@ html, body {
.editor { .editor {
flex: 1; flex: 1;
display: flex; display: flex;
min-width: 0;
max-width: 100%;
background: var(--color-bg); background: var(--color-bg);
overflow: visible; overflow: hidden;
} }
.editor-placeholder { .editor-placeholder {
@ -716,23 +724,6 @@ body.dragging-active * {
font-weight: 600; font-weight: 600;
} }
.drag-handle {
cursor: grab;
padding: 4px 6px;
font-size: 1.05rem;
line-height: 1;
color: var(--color-muted);
opacity: 0.6;
border-radius: 4px;
transition: opacity 0.12s ease, color 0.12s ease;
user-select: none;
}
.chapter-row:hover .drag-handle {
opacity: 0.9;
color: var(--color-on-accent);
}
.row-title { .row-title {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@ -909,6 +900,7 @@ body.dragging-active * {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@ -917,17 +909,22 @@ body.dragging-active * {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.chapter-header { .chapter-header {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
overflow-x: clip;
} }
.chapter-title { .chapter-title {
@ -936,6 +933,11 @@ body.dragging-active * {
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
} }
.word-count { .word-count {
@ -946,6 +948,11 @@ body.dragging-active * {
.editor-actions { .editor-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
row-gap: 6px;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
gap: 10px; gap: 10px;
color: var(--color-muted); color: var(--color-muted);
font-size: 0.72rem; font-size: 0.72rem;
@ -1156,9 +1163,13 @@ body.dragging-active * {
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: nowrap; align-content: flex-start;
overflow: visible; flex-wrap: wrap;
overflow-x: clip;
max-width: 100%;
min-width: 0;
gap: 4px; gap: 4px;
row-gap: 6px;
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-panel); background: var(--color-panel);
@ -1170,11 +1181,15 @@ body.dragging-active * {
.toolbar-group { .toolbar-group {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
row-gap: 6px;
gap: 2px; gap: 2px;
padding-right: 8px; padding-right: 8px;
margin-right: 8px; margin-right: 8px;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
flex: 0 0 auto; flex: 0 1 auto;
min-width: 0;
max-width: 100%;
} }
.toolbar-group:last-child { .toolbar-group:last-child {
@ -1991,3 +2006,40 @@ body:not(.dragging-active) .chapter-row {
animation: none !important; animation: none !important;
} }
} }
/* ---- narrow / tiled windows (Hyprland split-screen etc.) ----
Below ~900px the sidebar + full header + toolbar can't fit side by side,
so shrink the chrome instead of overflowing past the tile edge. */
@media (max-width: 900px) {
.sidebar {
flex-basis: 220px;
min-width: 200px;
}
.brand-icon {
width: 48px;
height: 48px;
}
.chapter-header {
padding: 10px 12px;
}
.editor-toolbar {
padding: 6px 8px;
}
.toolbar-select {
max-width: 110px;
}
.toolbar-group {
padding-right: 6px;
margin-right: 6px;
}
}
@media (max-width: 640px) {
.sidebar {
flex-basis: 200px;
min-width: 180px;
}
.chapter-title {
font-size: 1.15rem;
}
}

View file

@ -57,7 +57,7 @@
<div class="welcome-actions"> <div class="welcome-actions">
<button class="button" id="welcomeNewBook">New</button> <button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button> <button class="button button-ghost" id="welcomeOpenBook">Open</button>
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown file as a new book">Open .md</button> <button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown or text file as a new book">Open file</button>
<button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button> <button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button>
</div> </div>
<div class="recents-block"> <div class="recents-block">

41
tests/run-search-test.mjs Normal file
View file

@ -0,0 +1,41 @@
// Standalone test for the whole-book search helpers (src/renderer/book/ui.ts).
// These pure functions power the Search modal: Markdown flattening, literal
// matching (phrases included), and result snippets.
// Run with: node tests/run-search-test.mjs
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: ui, cleanupBundle } = await bundleForTest({
name: "folio-search-ui",
entryPoints: ["src/renderer/book/ui.ts"],
});
const { findMatches, markdownToPlain, makeSnippet, moveInList, escapeHtml } = ui;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// markdown -> searchable plain text
ok(markdownToPlain("# Title\nSome **bold** text") === "Title\nSome bold text", "headings/emphasis flattened");
ok(markdownToPlain("[link](http://x)") === "link", "links flattened to text");
ok(markdownToPlain("`code`") === "code", "inline code flattened");
// literal matching incl. phrases, case handling
ok(JSON.stringify(findMatches("hello world hello", "hello", true)) === "[0,12]", "finds all occurrences");
ok(findMatches("Hello", "hello", true).length === 0, "match-case respected");
ok(findMatches("Hello", "hello", false).length === 1, "case-insensitive match");
ok(JSON.stringify(findMatches("the quick brown fox", "quick brown", false)) === "[4]", "phrase search works");
ok(findMatches("a.b", "a.b", false).length === 1, "regex chars treated literally");
// snippets highlight the match and escape HTML
const snip = makeSnippet("say <hi> & bye", 4, 4);
ok(snip.includes("<mark>") && snip.includes("&lt;hi&gt;"), "snippet highlights + escapes");
// shared reorder math used by drag + keyboard reorder
ok(JSON.stringify(moveInList([1, 2, 3], 0, 2)) === "[2,3,1]", "moveInList moves item");
ok(escapeHtml("<a>&") === "&lt;a&gt;&amp;", "escapeHtml works");
cleanupBundle();
if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); }
console.log("All search checks passed.");

View file

@ -44,6 +44,37 @@ assert(sh.includes(`'/tmp/it'\\''s.tar.gz'`), "shQuote escapes embedded single q
assert(sh.includes("kill -0"), "installer waits for app pid"); assert(sh.includes("kill -0"), "installer waits for app pid");
assert(sh.includes("-xzf"), "installer extracts gz"); assert(sh.includes("-xzf"), "installer extracts gz");
// Real Gitea release-JSON shape (tag_name/html_url/body/assets with sizes).
const gitea = u.parseRelease({
id: 5,
tag_name: "v0.2.0",
html_url: "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0",
body: "Bug fixes",
assets: [
{ name: "Folio-0.2.0-linux-x86_64.tar.gz", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-x86_64.tar.gz", size: 123456 },
{ name: "Folio-0.2.0-linux-amd64.deb", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-amd64.deb", size: 111 },
{ name: "Folio-0.2.0.AppImage", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0.AppImage", size: 222 },
],
});
assert(gitea.version === "0.2.0", "gitea tag parsed");
assert(gitea.url === "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0", "gitea release url kept");
assert(gitea.notes === "Bug fixes", "gitea notes kept");
assert(u.pickAsset(gitea.assets).name.endsWith(".tar.gz"), "gitea: tar.gz picked over deb/AppImage");
assert(u.isTrustedDownloadUrl(gitea.assets[0].browser_download_url, "https://git.atitlan.io"), "gitea asset url trusted");
// No installable archive cases.
assert(u.pickAsset([]) === null, "empty assets -> no pick");
const debOnly = u.parseRelease({ tag_name: "v0.3.0", assets: [{ name: "Folio.deb", browser_download_url: "https://git.atitlan.io/x/Folio.deb" }] });
assert(u.pickAsset(debOnly.assets) === null, "deb-only release -> no pick (tar.gz required)");
// Version edge cases.
assert(!u.isNewer("0.1.0", "0.1.0"), "same version not an update");
assert(!u.isNewer("0.1.0", "0.2.0"), "older release not an update");
assert(u.compareVersions("0.1.2-rc1", "0.1.2") === 0, "rc suffix ignored in compare");
assert(u.parseVersion("nope") === null, "unparseable version -> null");
assert(!u.isTrustedDownloadUrl("http://git.atitlan.io/a.tar.gz", "https://git.atitlan.io"), "http vs https rejected");
assert(!u.isTrustedDownloadUrl("not a url", "https://git.atitlan.io"), "malformed url rejected");
assert(m.mdTitle("# My Heading\nbody", "file") === "My Heading", "title from H1"); assert(m.mdTitle("# My Heading\nbody", "file") === "My Heading", "title from H1");
assert(m.mdTitle("no heading", "file") === "file", "title falls back to filename stem"); assert(m.mdTitle("no heading", "file") === "file", "title falls back to filename stem");

View file

@ -0,0 +1,123 @@
// Sandbox execution test for the updater handoff script (buildInstallerScript).
// Runs the generated /bin/sh script against fake install dirs in os.tmpdir() —
// never the real ~/.local/share/Folio — proving the swap works and failures
// always relaunch the OLD version.
// Run with: node tests/run-update-installer-test.mjs
import { execFileSync, spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: u, cleanupBundle } = await bundleForTest({
name: "folio-update-script",
entryPoints: ["src/main/update.ts"],
});
let fails = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { fails++; console.error("FAIL -", msg); }
}
function sleepPid(ms) {
// Short-lived stand-in for the app process the script waits on.
const p = spawn("sleep", [String(ms / 1000)], { stdio: "ignore" });
return p.pid;
}
// A pid that is dead AND reaped (kill -0 fails), so the script proceeds
// without waiting. NOTE: a Node-spawned child that just exited stays a
// zombie while our event loop is blocked inside execFileSync — and kill -0
// succeeds on zombies — so the pid must be observed dead before running the
// script, not merely spawned with a short sleep.
async function deadPid() {
const p = spawn("sleep", ["0.01"], { stdio: "ignore" });
const pid = p.pid;
p.on("error", () => {});
for (let i = 0; i < 200; i++) {
try {
process.kill(pid, 0);
} catch {
return pid;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("helper process would not die");
}
function runScript(script, timeoutMs = 30000) {
execFileSync("/bin/sh", [script], { timeout: timeoutMs, stdio: "ignore" });
}
// Fake "installed" app tree: an executable `folio` stub + a data file.
function makeInstall(box, versionMarker) {
const dir = path.join(box, "install");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(dir, "other.txt"), "keepme");
return dir;
}
// Fake release tarball with an electron-builder-style nested top-level dir.
function makeTarball(box, versionMarker) {
const staged = path.join(box, "staged", "Folio-linux-x64");
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(staged, "extra.txt"), "newfile");
const tarball = path.join(box, "release.tar.gz");
execFileSync("tar", ["-czf", tarball, "-C", path.join(box, "staged"), "Folio-linux-x64"]);
return tarball;
}
// Relaunch stub records that it was invoked.
function makeRelaunch(box) {
const marker = path.join(box, "relaunched");
const launcher = path.join(box, "relaunch.sh");
fs.writeFileSync(launcher, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, { mode: 0o755 });
return { launcher, marker };
}
function writeScript(box, spec) {
const script = path.join(box, "installer.sh");
fs.writeFileSync(script, u.buildInstallerScript(spec), { mode: 0o700 });
return script;
}
// --- success path: valid tarball replaces the install, relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-ok-"));
const install = makeInstall(box, "OLD");
const tarball = makeTarball(box, "NEW");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800)); // relaunched in background (&)
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("NEW"), "install swapped to new version");
ok(fs.readFileSync(path.join(install, "extra.txt"), "utf-8") === "newfile", "new files land in install");
ok(fs.existsSync(marker), "relaunch invoked after swap");
ok(!fs.existsSync(tarball), "tarball cleaned up after success");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "old tree pruned after success");
fs.rmSync(box, { recursive: true, force: true });
}
// --- failure path: corrupt tarball keeps the old install, still relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-bad-"));
const install = makeInstall(box, "OLD");
const tarball = path.join(box, "release.tar.gz");
fs.writeFileSync(tarball, "this is not a gzip stream");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800));
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("OLD"), "old install intact after bad tarball");
ok(fs.existsSync(marker), "relaunch still invoked after failure");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "no .old residue after failure");
fs.rmSync(box, { recursive: true, force: true });
}
cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed.");