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:
parent
1413e73015
commit
542a545c0d
17 changed files with 445 additions and 73 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ dist/
|
|||
*.asar
|
||||
releases/
|
||||
.DS_Store
|
||||
.directory
|
||||
tests/.tmp-folio-format-*.mjs
|
||||
|
||||
.opencode/
|
||||
|
|
|
|||
|
|
@ -22,6 +22,25 @@ linux:
|
|||
- AppImage
|
||||
- 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.
|
||||
# Setting `depends` replaces the auto-generated list, so keep its entries and
|
||||
# add what Electron links but the defaults omit — notably libasound2 (ALSA),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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 fs from "fs";
|
||||
import path from "path";
|
||||
|
|
@ -86,8 +86,8 @@ function createWindow() {
|
|||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 780,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
minWidth: 560,
|
||||
minHeight: 400,
|
||||
title: "Folio",
|
||||
icon: path.join(__dirname, "icons", "icon.png"),
|
||||
webPreferences: {
|
||||
|
|
@ -97,6 +97,7 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
forwardEditShortcuts(mainWindow);
|
||||
wireContextMenu(mainWindow);
|
||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("[folio] renderer loaded");
|
||||
|
|
@ -127,6 +128,15 @@ function createWindow() {
|
|||
|
||||
app.whenReady().then(() => {
|
||||
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(
|
||||
getWindow,
|
||||
getState,
|
||||
|
|
@ -710,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => {
|
|||
return win.isFullScreen();
|
||||
});
|
||||
|
||||
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste
|
||||
// via the mouse work. The roles act on whatever element is focused.
|
||||
ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const menu = Menu.buildFromTemplate([
|
||||
// Right-click menu with spelling suggestions (English-only dictionary).
|
||||
// Electron builds no default context menu, so without this the red
|
||||
// misspelling underlines had no way to reach a suggestion. Chromium reports
|
||||
// the misspelled word + candidates via the webContents event; suggestions
|
||||
// replace the word in place, and "Add to dictionary" persists to the
|
||||
// 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: "copy" },
|
||||
{ role: "paste" },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll" },
|
||||
]);
|
||||
menu.popup({ window: win, x, y });
|
||||
{ role: "selectAll" }
|
||||
);
|
||||
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
|
||||
|
|
@ -901,9 +949,9 @@ handleIpc("folio:importAttachment", async () => {
|
|||
// never modified. Books land in Documents, same as "New".
|
||||
handleIpc("folio:importMarkdown", async () => {
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
title: "Open Markdown file(s)",
|
||||
title: "Open Markdown or text file(s)",
|
||||
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;
|
||||
return importMarkdownFiles(filePaths, app.getPath("documents"));
|
||||
|
|
@ -956,6 +1004,7 @@ interface UpdateCheckResult {
|
|||
updateAvailable?: boolean;
|
||||
assetName?: string;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
canInstall?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -983,6 +1032,7 @@ handleIpc("folio:checkUpdate", async (): Promise<UpdateCheckResult> => {
|
|||
updateAvailable: isNewer(rel.version, current),
|
||||
assetName: asset ? asset.name : undefined,
|
||||
notes: rel.notes,
|
||||
url: rel.url,
|
||||
canInstall:
|
||||
!!asset &&
|
||||
isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) &&
|
||||
|
|
@ -1035,7 +1085,21 @@ handleIpc("folio:performUpdate", async () => {
|
|||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||
});
|
||||
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) {
|
||||
fs.rmSync(tarball, { force: true });
|
||||
return { error: "Downloaded file is not a valid archive; update aborted." };
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import path from "path";
|
||||
import { app, dialog, Menu, BrowserWindow } from "electron";
|
||||
import { app, Menu, BrowserWindow } from "electron";
|
||||
import { listRecents } from "./recents.js";
|
||||
import type { Recent } from "./recents.js";
|
||||
import { setRecoveryRoot } from "./backup.js";
|
||||
|
||||
type MenuItem = Electron.MenuItemConstructorOptions;
|
||||
type GetState = () => { bookPath: string | null };
|
||||
|
|
@ -39,7 +38,7 @@ export function buildMenu(
|
|||
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
|
||||
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
|
||||
{
|
||||
label: "Open Markdown File…",
|
||||
label: "Open File…",
|
||||
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
||||
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
|
||||
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
|
||||
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export function buildInstallerScript(spec: InstallerSpec): string {
|
|||
`OLD="$INSTALL.old.$$"`,
|
||||
"# Wait (bounded to ~2 minutes) for the app process to exit.",
|
||||
"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"',
|
||||
'mkdir -p "$NEW"',
|
||||
'if tar -xzf "$TARBALL" -C "$NEW"; then',
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
li.draggable = false;
|
||||
li.tabIndex = -1;
|
||||
li.innerHTML = `
|
||||
<span class="drag-handle" title="Drag to reorder">⋮</span>
|
||||
<span class="row-title" title="${ch.title}">${ch.title}</span>
|
||||
<span class="row-actions">
|
||||
<button class="icon-btn" data-act="color" title="Chapter color">●</button>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
});
|
||||
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
|
||||
// lengths. Keep the surrounding editor area clickable so a new chapter can
|
||||
// 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-center", fmt.align === "center");
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ export function initBook(api: FolioAPI) {
|
|||
// server, then offer a one-click install. The app downloads the tarball,
|
||||
// hands off to a detached swap script, and relaunches itself — no manual
|
||||
// reinstall. See src/main/update.ts for the safety properties.
|
||||
let updateProgressWired = false;
|
||||
async function checkForUpdates() {
|
||||
showToast("Checking for updates…");
|
||||
const res = await api.checkUpdate();
|
||||
|
|
@ -164,18 +165,23 @@ export function initBook(api: FolioAPI) {
|
|||
if (!res.canInstall) {
|
||||
await alertMessage(
|
||||
"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;
|
||||
}
|
||||
// Registered once: checkForUpdates can run repeatedly per session and
|
||||
// ipcRenderer.on would otherwise stack duplicate progress listeners.
|
||||
if (!updateProgressWired) {
|
||||
updateProgressWired = true;
|
||||
let lastPct = -1;
|
||||
api.onUpdateProgress(({ received, total }) => {
|
||||
const pct = total ? Math.floor((received / total) * 100) : -1;
|
||||
if (pct !== lastPct) {
|
||||
if (pct === lastPct) return;
|
||||
lastPct = pct;
|
||||
showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`);
|
||||
}
|
||||
});
|
||||
}
|
||||
const up = await api.performUpdate();
|
||||
if (up && up.error) {
|
||||
await alertMessage("Update failed", up.error);
|
||||
|
|
|
|||
|
|
@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
|
|||
// any of those closes the dropdowns.
|
||||
window.addEventListener("scroll", closeDropdowns, true);
|
||||
window.addEventListener("resize", closeDropdowns);
|
||||
// Electron has no default right-click menu, so show one with Cut/Copy/Paste.
|
||||
window.addEventListener("contextmenu", (e) => {
|
||||
// Let Chromium show its native spelling suggestions when the target is a
|
||||
// misspelled word. The custom menu is used for the rest of the editor.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
api.showContextMenu(e.clientX, e.clientY);
|
||||
});
|
||||
// Note: right-click is handled by the main process (webContents
|
||||
// context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM
|
||||
// contextmenu listener here — preventing the default would suppress the
|
||||
// native suggestion menu for misspelled words.
|
||||
|
||||
const headingSelect = $("headingSelect") as HTMLSelectElement;
|
||||
headingSelect.addEventListener("change", () => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ export type FolioAPI = {
|
|||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
showContextMenu: (x: number, y: number) => void;
|
||||
getSpellcheck: () => Promise<boolean>;
|
||||
setSpellcheck: (on: boolean) => Promise<any>;
|
||||
getBookMeta: () => Promise<BookMeta | { error: string }>;
|
||||
setComments: (comments: Record<string, unknown>) => Promise<any>;
|
||||
getChapterList: () => Promise<any[]>;
|
||||
|
|
|
|||
|
|
@ -44,23 +44,29 @@ html, body {
|
|||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 280px;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
max-width: min(360px, 85vw);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-panel);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--gap);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
|
|
@ -342,8 +348,10 @@ html, body {
|
|||
.editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: var(--color-bg);
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
|
|
@ -716,23 +724,6 @@ body.dragging-active * {
|
|||
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 {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
|
@ -909,6 +900,7 @@ body.dragging-active * {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -917,17 +909,22 @@ body.dragging-active * {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
|
|
@ -936,6 +933,11 @@ body.dragging-active * {
|
|||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
|
|
@ -946,6 +948,11 @@ body.dragging-active * {
|
|||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 10px;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.72rem;
|
||||
|
|
@ -1156,9 +1163,13 @@ body.dragging-active * {
|
|||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow: visible;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: clip;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
row-gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-panel);
|
||||
|
|
@ -1170,11 +1181,15 @@ body.dragging-active * {
|
|||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
margin-right: 8px;
|
||||
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 {
|
||||
|
|
@ -1991,3 +2006,40 @@ body:not(.dragging-active) .chapter-row {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
<div class="welcome-actions">
|
||||
<button class="button" id="welcomeNewBook">New</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>
|
||||
</div>
|
||||
<div class="recents-block">
|
||||
|
|
|
|||
41
tests/run-search-test.mjs
Normal file
41
tests/run-search-test.mjs
Normal 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("<hi>"), "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>&") === "<a>&", "escapeHtml works");
|
||||
|
||||
cleanupBundle();
|
||||
if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); }
|
||||
console.log("All search checks passed.");
|
||||
|
|
@ -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("-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("no heading", "file") === "file", "title falls back to filename stem");
|
||||
|
||||
|
|
|
|||
123
tests/run-update-installer-test.mjs
Normal file
123
tests/run-update-installer-test.mjs
Normal 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.");
|
||||
Loading…
Add table
Add a link
Reference in a new issue