WIP: save Folio changes before OpenCode restart

This commit is contained in:
avi 2026-08-17 18:02:44 -05:00
commit c7323af38f
8 changed files with 194 additions and 8 deletions

BIN
FolioAppIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, 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";
@ -68,7 +68,8 @@ function createWindow() {
const scenarioPath = path.join(__dirname, "..", "tests", "selftest-scenario.js"); const scenarioPath = path.join(__dirname, "..", "tests", "selftest-scenario.js");
const scenario = fs const scenario = fs
.readFileSync(scenarioPath, "utf-8") .readFileSync(scenarioPath, "utf-8")
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir)); .replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir))
.replace(/__BOOKDIR2_VALUE__/, JSON.stringify(process.env.FOLIO_TEST_BOOK2 || ""));
mainWindow?.webContents mainWindow?.webContents
.executeJavaScript(scenario, false) .executeJavaScript(scenario, false)
.then((summary) => { .then((summary) => {
@ -124,7 +125,12 @@ function forwardEditShortcuts(win: BrowserWindow) {
const mod = input.control || input.meta; const mod = input.control || input.meta;
if (!mod) return; if (!mod) return;
const key = input.key.toLowerCase(); const key = input.key.toLowerCase();
if (key === "z" && !input.shift) { if (key === "a" && !input.shift && !input.alt) {
// Route Ctrl+A to the renderer so a read-only CodeMirror view (Full Book)
// can select its whole document instead of the native page-wide selectAll.
event.preventDefault();
win.webContents.send("folio:selectAll-requested");
} else if (key === "z" && !input.shift) {
event.preventDefault(); event.preventDefault();
win.webContents.send("folio:undo-requested"); win.webContents.send("folio:undo-requested");
} else if ((key === "z" && input.shift) || (key === "y" && !input.shift)) { } else if ((key === "z" && input.shift) || (key === "y" && !input.shift)) {
@ -323,6 +329,15 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
} }
}); });
ipcMain.handle("folio:copyText", (_e, text: string) => {
clipboard.writeText(typeof text === "string" ? text : "");
return { ok: true };
});
ipcMain.handle("folio:getClipboardText", () => {
return clipboard.readText();
});
ipcMain.handle("folio:importAttachment", async () => { ipcMain.handle("folio:importAttachment", async () => {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({

View file

@ -81,7 +81,11 @@ export function buildMenu(
{ role: "copy" }, { role: "copy" },
{ role: "paste" }, { role: "paste" },
{ type: "separator" }, { type: "separator" },
{ role: "selectAll" }, {
label: "Select All",
accelerator: "CmdOrCtrl+A",
click: () => getWindow()?.webContents.send("folio:selectAll-requested"),
},
], ],
}; };

View file

@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", {
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
// events // events
onBookOpened: (cb: (data: { path: string; title: string }) => void) => onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
@ -52,4 +54,6 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:undo-requested", () => cb()), ipcRenderer.on("folio:undo-requested", () => cb()),
onRedoRequested: (cb: () => void) => onRedoRequested: (cb: () => void) =>
ipcRenderer.on("folio:redo-requested", () => cb()), ipcRenderer.on("folio:redo-requested", () => cb()),
onSelectAllRequested: (cb: () => void) =>
ipcRenderer.on("folio:selectAll-requested", () => cb()),
}); });

View file

@ -2,7 +2,7 @@ import type { BookMeta } from "../main/project.js";
import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js";
import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; import { createMdEditor, editorText, setEditorText } from "./md-editor.js";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
import { redo, undo, indentLess, indentMore } from "@codemirror/commands"; import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands";
import { import {
applyFormatPainter, applyFormatPainter,
captureFormat, captureFormat,
@ -54,6 +54,8 @@ export type FolioAPI = {
exportMarkdown: () => Promise<any>; exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>; exportZip: () => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void; onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void;
@ -65,6 +67,7 @@ export type FolioAPI = {
onExportZipRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void;
onUndoRequested: (cb: () => void) => void; onUndoRequested: (cb: () => void) => void;
onRedoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void;
}; };
const $ = (id: string) => document.getElementById(id) as HTMLElement; const $ = (id: string) => document.getElementById(id) as HTMLElement;
@ -106,6 +109,18 @@ export function initBook(api: FolioAPI) {
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement; const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
const projectBar = $("projectBar"); const projectBar = $("projectBar");
const fullBookNav = $("fullBookNav");
const fullBookBtn = $("fullBookBtn") as HTMLButtonElement;
const fullBookView = $("fullBookView");
const fullBookHost = $("fullBookHost");
const fullBookStats = $("fullBookStats");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus");
const fullBookEditor: EditorView = createMdEditor(fullBookHost, {
onChange: () => undefined,
readOnly: true,
});
const editorView: EditorView = createMdEditor(editorHost, { const editorView: EditorView = createMdEditor(editorHost, {
onChange: (text) => handleEditorChange(text), onChange: (text) => handleEditorChange(text),
@ -325,6 +340,7 @@ export function initBook(api: FolioAPI) {
function setChapterButtons(enabled: boolean) { function setChapterButtons(enabled: boolean) {
addChapterBtn.disabled = !enabled; addChapterBtn.disabled = !enabled;
newChapterBtn.disabled = !enabled; newChapterBtn.disabled = !enabled;
fullBookNav.classList.toggle("hidden", !enabled);
} }
let dirty = false; let dirty = false;
let loadedContent = ""; let loadedContent = "";
@ -338,6 +354,7 @@ export function initBook(api: FolioAPI) {
function showWelcome() { function showWelcome() {
welcome.classList.remove("hidden"); welcome.classList.remove("hidden");
chapterView.classList.add("hidden"); chapterView.classList.add("hidden");
fullBookView.classList.add("hidden");
editorPlaceholder.classList.add("hidden"); editorPlaceholder.classList.add("hidden");
projectBar.classList.add("hidden"); projectBar.classList.add("hidden");
currentBook = null; currentBook = null;
@ -345,11 +362,15 @@ export function initBook(api: FolioAPI) {
ordered = []; ordered = [];
selectedId = null; selectedId = null;
setChapterButtons(false); setChapterButtons(false);
fullBookBtn.classList.remove("active");
setEditorText(fullBookEditor, "");
renderChapterList(); renderChapterList();
} }
function showPlaceholder() { function showPlaceholder() {
chapterView.classList.add("hidden"); chapterView.classList.add("hidden");
fullBookView.classList.add("hidden");
fullBookBtn.classList.remove("active");
editorPlaceholder.classList.remove("hidden"); editorPlaceholder.classList.remove("hidden");
} }
@ -377,8 +398,10 @@ export function initBook(api: FolioAPI) {
} }
setChapterButtons(!!meta); setChapterButtons(!!meta);
renderChapterList(); renderChapterList();
if (ordered.length) showPlaceholder(); if (fullBookView.classList.contains("hidden")) {
else showPlaceholder(); if (ordered.length) showPlaceholder();
else showPlaceholder();
}
} }
async function refreshMeta() { async function refreshMeta() {
@ -387,6 +410,7 @@ export function initBook(api: FolioAPI) {
selectedId = null; selectedId = null;
showPlaceholder(); showPlaceholder();
} }
await refreshFullBook();
} }
function renderChapterList() { function renderChapterList() {
@ -456,6 +480,8 @@ export function initBook(api: FolioAPI) {
editorView.dispatch({ selection: { anchor: text.length } }); editorView.dispatch({ selection: { anchor: text.length } });
editorView.focus(); editorView.focus();
clearTimers(); clearTimers();
fullBookBtn.classList.remove("active");
fullBookView.classList.add("hidden");
chapterView.classList.remove("hidden"); chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden"); editorPlaceholder.classList.add("hidden");
} catch { } catch {
@ -463,6 +489,69 @@ export function initBook(api: FolioAPI) {
} }
} }
// ---- full book view ----
function chapterHasOwnHeading(content: string, title: string): boolean {
const t = title.trim().toLowerCase();
if (!t) return true;
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
if (m && m[1].trim().toLowerCase() === t) return true;
}
return false;
}
async function assembleFullBook(): Promise<string> {
const parts: string[] = [];
for (const ch of ordered) {
const res = await api.getChapterContent(ch.id);
const content = typeof res === "string" ? res : "";
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const block: string[] = [];
if (!chapterHasOwnHeading(content, ch.title)) {
block.push(`# ${ch.title}`);
if (body) {
block.push("");
block.push(body);
}
} else if (body) {
block.push(body);
}
if (block.length) parts.push(block.join("\n"));
}
return parts.join("\n\n");
}
async function renderFullBook() {
const text = await assembleFullBook();
setEditorText(fullBookEditor, text);
fullBookStats.textContent = `words: ${countWords(text)} · chars: ${text.length}`;
}
async function selectFullBook() {
if (selectedId && dirty) {
const res = await api.saveChapter(selectedId, editorText(editorView));
if (!(res && "error" in res)) loadedContent = editorText(editorView);
dirty = false;
saveStateEl.textContent = "Saved";
saveStateEl.classList.remove("unsaved");
}
clearTimers();
selectedId = null;
chapterList.querySelectorAll(".chapter-row").forEach((r) => r.classList.remove("active"));
fullBookBtn.classList.add("active");
chapterView.classList.add("hidden");
editorPlaceholder.classList.add("hidden");
fullBookView.classList.remove("hidden");
await renderFullBook();
fullBookEditor.focus();
}
async function refreshFullBook() {
if (fullBookView.classList.contains("hidden")) return;
await renderFullBook();
}
function clearTimers() { function clearTimers() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
} }
@ -716,6 +805,25 @@ export function initBook(api: FolioAPI) {
addChapterBtn.addEventListener("click", () => void create()); addChapterBtn.addEventListener("click", () => void create());
exportBtn.addEventListener("click", () => void exportMarkdown()); exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip()); exportZipBtn.addEventListener("click", () => void exportZip());
fullBookBtn.addEventListener("click", () => void selectFullBook());
copyAllBtn.addEventListener("click", async () => {
const text = editorText(fullBookEditor);
if (!text) {
fullBookStatus.textContent = "Nothing to copy";
return;
}
const res = await api.copyText(text);
if (res && "error" in res) {
await alertMessage("Could not copy", (res as { error: string }).error);
return;
}
fullBookStatus.textContent = "Full book copied";
fullBookStatus.classList.add("unsaved");
setTimeout(() => {
fullBookStatus.textContent = "";
fullBookStatus.classList.remove("unsaved");
}, 3000);
});
$("revealBtn").addEventListener("click", () => { $("revealBtn").addEventListener("click", () => {
if (currentBook) void api.revealInFolder(currentBook.path); if (currentBook) void api.revealInFolder(currentBook.path);
}); });

View file

@ -117,6 +117,40 @@ html, body {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
/* ---- full book nav ---- */
.full-book-nav {
margin-top: var(--gap);
padding-top: var(--gap);
border-top: 1px solid var(--color-border);
}
.full-book-btn {
width: 100%;
text-align: left;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-family: var(--font-serif);
font-size: 0.95rem;
padding: 8px 10px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.full-book-btn:hover {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.full-book-btn.active {
background: var(--color-accent);
color: #fff;
font-weight: 600;
border-color: var(--color-accent);
}
.button { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View file

@ -19,6 +19,9 @@
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button> <button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
</div> </div>
<ul class="chapter-list" id="chapterList"></ul> <ul class="chapter-list" id="chapterList"></ul>
<div class="full-book-nav hidden" id="fullBookNav">
<button class="full-book-btn" id="fullBookBtn" title="View the entire book as one continuous manuscript">Full Book</button>
</div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button> <button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
</div> </div>
@ -161,6 +164,17 @@
</div> </div>
<div class="editor-panes" id="editorHost"></div> <div class="editor-panes" id="editorHost"></div>
</div> </div>
<div class="full-book-view hidden" id="fullBookView">
<header class="chapter-header">
<h2 class="chapter-title">Full Book</h2>
<div class="editor-actions">
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
<span class="save-state" id="fullBookStatus"></span>
</div>
</header>
<div class="editor-panes" id="fullBookHost"></div>
</div>
<div class="editor-placeholder hidden" id="editorPlaceholder"> <div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p> <p>Select a chapter to begin writing.</p>
</div> </div>

View file

@ -22,6 +22,7 @@ import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderl
export interface MdEditorCallbacks { export interface MdEditorCallbacks {
onChange: (text: string) => void; onChange: (text: string) => void;
onSelection?: (view: EditorView) => void; onSelection?: (view: EditorView) => void;
readOnly?: boolean;
} }
const formatBindings: KeyBinding[] = [ const formatBindings: KeyBinding[] = [
@ -101,6 +102,7 @@ const folioTheme = EditorView.theme({
}); });
export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView { export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView {
const readOnly = !!callbacks.readOnly;
const view = new EditorView({ const view = new EditorView({
state: EditorState.create({ state: EditorState.create({
doc: "", doc: "",
@ -117,7 +119,12 @@ export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks):
search({ top: true }), search({ top: true }),
syntaxHighlighting(mdHighlight), syntaxHighlighting(mdHighlight),
folioTheme, folioTheme,
keymap.of([...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]), ...(readOnly ? [EditorState.readOnly.of(true)] : []),
keymap.of(
readOnly
? [...defaultKeymap, ...searchKeymap]
: [...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]
),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.onChange(update.state.doc.toString()); if (update.docChanged) callbacks.onChange(update.state.doc.toString());
if (callbacks.onSelection && (update.docChanged || update.selectionSet)) { if (callbacks.onSelection && (update.docChanged || update.selectionSet)) {