Stage 2: local project creation — folder dialog, folio.json, recents, menu, book open/new/reveal, welcome UI

This commit is contained in:
avi 2026-08-16 14:11:51 -05:00
commit 2862c433bd
10 changed files with 710 additions and 14 deletions

View file

@ -13,6 +13,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",
"package": "electron-builder"
},
"devDependencies": {

View file

@ -1,7 +1,32 @@
import { app, BrowserWindow } from "electron";
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import type { OpenDialogOptions } from "electron";
import path from "path";
import {
BookResult,
ErrResult,
createBook,
hasBook,
loadBook,
Recent,
} from "./project.js";
import { listRecents, addRecent } from "./recents.js";
import { buildMenu } from "./menu.js";
let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null;
function getWindow(): BrowserWindow | null {
return mainWindow;
}
function showOpenDialog(options: OpenDialogOptions) {
const win = getWindow();
return win ? dialog.showOpenDialog(win, options) : dialog.showOpenDialog(options);
}
function getState() {
return { bookPath };
}
function createWindow() {
mainWindow = new BrowserWindow({
@ -16,7 +41,6 @@ function createWindow() {
preload: path.join(__dirname, "preload.js"),
},
});
mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.webContents.on("did-finish-load", () =>
console.log("[folio] renderer loaded")
@ -24,6 +48,12 @@ function createWindow() {
}
app.whenReady().then(() => {
buildMenu(
getWindow,
getState,
() => getWindow()?.webContents.send("folio:new-book-requested"),
() => getWindow()?.webContents.send("folio:open-book-requested")
);
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
@ -33,3 +63,74 @@ app.whenReady().then(() => {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
function refreshMenu() {
buildMenu(
getWindow,
getState,
() => getWindow()?.webContents.send("folio:new-book-requested"),
() => getWindow()?.webContents.send("folio:open-book-requested")
);
}
async function openBookAt(p: string): Promise<BookResult | ErrResult> {
try {
const meta = loadBook(p);
addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() });
bookPath = p;
mainWindow?.setTitle(`${meta.title} — Folio`);
refreshMenu();
mainWindow?.webContents.send("folio:bookOpened", { path: p, title: meta.title });
return { path: p, title: meta.title };
} catch (e) {
return { error: (e as Error).message };
}
}
ipcMain.handle("folio:newBook", async () => {
const { canceled, filePaths } = await showOpenDialog({
properties: ["createDirectory", "openDirectory"],
title: "Choose where to save your new book",
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const p = filePaths[0];
if (hasBook(p)) return { error: "A Folio book already exists at this location." };
const title = path.basename(p) || "Untitled";
const meta = createBook(p, title);
addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() });
bookPath = p;
mainWindow?.setTitle(`${meta.title} — Folio`);
refreshMenu();
getWindow()?.webContents.send("folio:bookOpened", { path: p, title: meta.title });
return { path: p, title: meta.title };
});
ipcMain.handle("folio:openBook", async () => {
const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"],
title: "Open a Folio book",
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const p = filePaths[0];
if (!hasBook(p)) {
return { error: "No Folio book (folio.json) found at this location." };
}
return openBookAt(p);
});
ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p));
ipcMain.handle("folio:reveal", async (_e, p: string) => {
if (p) await shell.openPath(p);
return true;
});
ipcMain.handle("folio:getRecents", (): Recent[] => listRecents());
ipcMain.handle("folio:closeBook", () => {
bookPath = null;
mainWindow?.setTitle("Folio");
refreshMenu();
getWindow()?.webContents.send("folio:bookClosed");
return true;
});

71
src/main/menu.ts Normal file
View file

@ -0,0 +1,71 @@
import path from "path";
import { app, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js";
type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null };
type GetWindow = () => BrowserWindow | null;
export function buildMenu(
getWindow: GetWindow,
getState: GetState,
onRequestNew: () => void,
onRequestOpen: () => void
) {
const isMac = process.platform === "darwin";
const recents = listRecents();
const recentItems: MenuItem[] = recents.length
? recents.map((r: Recent) => ({
label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path),
tooltip: r.path,
click: () => getWindow()?.webContents.send("folio:open-recent", r.path),
}))
: [{ label: "(no recent books)", enabled: false }];
const fileMenu: MenuItem = {
label: "File",
submenu: [
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ type: "separator" },
{ label: "Open Recent", submenu: recentItems },
{ type: "separator" },
{
label: "Reveal in Folder",
accelerator: "CmdOrCtrl+R",
enabled: !!getState().bookPath,
click: () => {
const p = getState().bookPath;
if (p) getWindow()?.webContents.send("folio:reveal-requested", p);
},
},
{ type: "separator" },
{
label: "Close",
accelerator: isMac ? "Cmd+W" : "Ctrl+W",
click: () => getWindow()?.close(),
},
{ label: "Quit", accelerator: "CmdOrCtrl+Q", click: () => app.quit() },
],
};
const editMenu: MenuItem = {
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
],
};
const menu = Menu.buildFromTemplate([fileMenu, editMenu]);
Menu.setApplicationMenu(menu);
return menu;
}

View file

@ -1,3 +1,22 @@
window.addEventListener("DOMContentLoaded", () => {
console.log("[folio] dom ready");
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("folio", {
newBook: () => ipcRenderer.invoke("folio:newBook"),
openBook: () => ipcRenderer.invoke("folio:openBook"),
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
onBookClosed: (cb: () => void) =>
ipcRenderer.on("folio:bookClosed", () => cb()),
onNewBookRequested: (cb: () => void) =>
ipcRenderer.on("folio:new-book-requested", () => cb()),
onOpenBookRequested: (cb: () => void) =>
ipcRenderer.on("folio:open-book-requested", () => cb()),
onOpenRecent: (cb: (p: string) => void) =>
ipcRenderer.on("folio:open-recent", (_e, p) => cb(p)),
onRevealRequested: (cb: (p: string) => void) =>
ipcRenderer.on("folio:reveal-requested", (_e, p) => cb(p)),
});

130
src/main/project.ts Normal file
View file

@ -0,0 +1,130 @@
import fs from "fs";
import path from "path";
export interface ChapterEntry {
id: string;
title: string;
file: string;
}
export interface BookMeta {
app: string;
version: number;
title: string;
chapterOrder: string[];
chapters: Record<string, ChapterEntry>;
}
export interface Recent {
path: string;
title: string;
lastOpened: string;
}
export interface BookResult {
path: string;
title: string;
}
export interface ErrResult {
error: string;
}
export const APP_ID = "folio";
export const META_FILE = "folio.json";
export const CHAPTERS_DIR = "chapters";
export const CURRENT_VERSION = 1;
export function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "chapter";
}
export function makeChapterId(title: string): string {
const rand = Math.random().toString(36).slice(2, 6);
return `${slugify(title) || "chapter"}-${rand}`;
}
export function chapterFileName(id: string): string {
return `${id}.md`;
}
export function defaultMeta(title: string): BookMeta {
return {
app: APP_ID,
version: CURRENT_VERSION,
title,
chapterOrder: [],
chapters: {},
};
}
export function metaPath(bookPath: string): string {
return path.join(bookPath, META_FILE);
}
export function chaptersDir(bookPath: string): string {
return path.join(bookPath, CHAPTERS_DIR);
}
export function hasBook(bookPath: string): boolean {
try {
return fs.statSync(metaPath(bookPath)).isFile();
} catch {
return false;
}
}
export function createBook(bookPath: string, title: string): BookMeta {
fs.mkdirSync(bookPath, { recursive: true });
fs.mkdirSync(chaptersDir(bookPath), { recursive: true });
const meta = defaultMeta(title);
saveMeta(bookPath, meta);
return meta;
}
export function loadBook(bookPath: string): BookMeta {
const raw = fs.readFileSync(metaPath(bookPath), "utf-8");
const data = JSON.parse(raw) as BookMeta;
if (data.app !== APP_ID) {
throw new Error(
`Not a Folio book (no valid folio.json): ${bookPath}`
);
}
// tolerate older/simpler metadata
if (!Array.isArray(data.chapterOrder)) data.chapterOrder = [];
if (!data.chapters) data.chapters = {};
return data;
}
export function saveMeta(bookPath: string, meta: BookMeta): void {
fs.writeFileSync(
metaPath(bookPath),
JSON.stringify(meta, null, 2),
"utf-8"
);
}
function ensureParent(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
export function listRecents(filePath: string): Recent[] {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const arr = JSON.parse(raw);
return Array.isArray(arr) ? (arr as Recent[]) : [];
} catch {
return [];
}
}
export function addRecent(filePath: string, recent: Recent): Recent[] {
const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
recents.unshift(recent);
ensureParent(filePath);
fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8");
return recents;
}

18
src/main/recents.ts Normal file
View file

@ -0,0 +1,18 @@
import path from "path";
import { app } from "electron";
import { APP_ID, listRecents as _listRecents, addRecent as _addRecent } from "./project.js";
import type { Recent } from "./project.js";
export type { Recent };
export function recentsFilePath(): string {
return path.join(app.getPath("userData"), `${APP_ID}-recents.json`);
}
export function listRecents(): Recent[] {
return _listRecents(recentsFilePath());
}
export function addRecent(recent: Recent): Recent[] {
return _addRecent(recentsFilePath(), recent);
}

View file

@ -158,3 +158,128 @@ html, body {
font-size: 1rem;
margin: 0 0 6px;
}
/* ---- project bar ---- */
.project-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--color-panel);
border-radius: var(--radius);
margin-bottom: var(--gap);
}
.book-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.book-path {
flex: 1;
font-size: 0.7rem;
color: var(--color-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-button {
border: 1px solid var(--color-border);
background: transparent;
border-radius: var(--radius);
padding: 4px 8px;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
transition: background 0.12s ease, border-color 0.12s ease;
}
.icon-button:hover {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
/* ---- welcome ---- */
.welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
max-width: 520px;
margin: 0 auto;
padding: var(--gap);
}
.app-name {
font-size: 2rem;
font-weight: 700;
margin: 0 0 4px;
color: var(--color-text);
}
.app-tagline {
font-size: 0.95rem;
color: var(--color-muted);
margin: 0 0 var(--gap);
}
.welcome-actions {
display: flex;
gap: 12px;
margin-bottom: var(--gap);
}
.recents-block {
width: 100%;
text-align: left;
}
.recents-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-muted);
margin-bottom: 8px;
}
.recent-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
}
.recent-list li {
padding: 6px 8px;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text);
transition: background 0.12s ease, color 0.12s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-list li:hover {
background: var(--color-accent);
color: #fff;
}
.muted {
color: var(--color-muted);
}
.hidden {
display: none !important;
}

View file

@ -8,19 +8,36 @@
</head>
<body>
<div id="root">
<aside class="sidebar" id="sidebar">
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Chapters</span>
</div>
<div class="project-bar hidden" id="projectBar">
<span class="book-title" id="bookTitle"></span>
<span class="book-path" id="bookPath" title=""></span>
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
</div>
<ul class="chapter-list" id="chapterList"></ul>
<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>
</aside>
<main class="editor" id="editor">
<div class="editor-placeholder">
<div class="welcome" id="welcome">
<h1 class="app-name">Folio</h1>
<p class="app-tagline">Your stories, in folders you choose.</p>
<div class="welcome-actions">
<button class="button" id="welcomeNewBook">New Book</button>
<button class="button button-ghost" id="welcomeOpenBook">Open Book</button>
</div>
<div class="recents-block">
<div class="recents-title">Recent Projects</div>
<ul class="recent-list" id="recentList">
<li class="muted">No recent books.</li>
</ul>
</div>
</div>
<div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p>
</div>
</main>

View file

@ -1,8 +1,137 @@
const root = document.getElementById("root")!;
type BookResultMsg = { path: string; title: string } | { canceled: true } | { error: string } | null;
export {};
root.addEventListener("dragover", (e) => e.preventDefault());
root.addEventListener("drop", (e) => e.preventDefault());
type FolioAPI = {
newBook: () => Promise<BookResultMsg>;
openBook: () => Promise<BookResultMsg>;
openBookPath: (p: string) => Promise<BookResultMsg>;
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
onRevealRequested: (cb: (p: string) => void) => void;
};
document.getElementById("newChapterBtn")!.addEventListener("click", () => {
alert("Chapter creation coming soon.");
declare global {
interface Window {
folio: FolioAPI;
}
}
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const welcome = $("welcome");
const placeholder = $("editorPlaceholder");
const projectBar = $("projectBar");
const bookTitle = $("bookTitle");
const bookPath = $("bookPath");
const revealBtn = $("revealBtn") as HTMLButtonElement;
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
const recentList = $("recentList");
let currentBook: { path: string; title: string } | null = null;
function showBook() {
welcome.classList.add("hidden");
placeholder.classList.remove("hidden");
projectBar.classList.remove("hidden");
}
function showWelcome() {
welcome.classList.remove("hidden");
placeholder.classList.add("hidden");
projectBar.classList.add("hidden");
}
function setCurrentBook(book: { path: string; title: string }) {
currentBook = book;
bookTitle.textContent = book.title;
bookPath.textContent = book.path;
bookPath.title = book.path;
showBook();
void refreshRecents();
}
function clearBook() {
currentBook = null;
showWelcome();
}
function handleResult(res: BookResultMsg) {
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) {
alert(res.error);
return;
}
setCurrentBook({ path: res.path, title: res.title });
}
async function startNewBook() {
const res = await window.folio.newBook();
handleResult(res);
}
async function startOpenBook() {
const res = await window.folio.openBook();
handleResult(res);
}
async function openPath(p: string) {
const res = await window.folio.openBookPath(p);
handleResult(res);
}
function renderRecents(recents: any[]) {
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents as { title: string; path: string }[]) {
const li = document.createElement("li");
li.textContent = r.title || r.path;
li.title = r.path;
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent sidebar blur/focus issues
void openPath(r.path);
});
recentList.appendChild(li);
}
}
async function refreshRecents() {
const recents = await window.folio.getRecents();
renderRecents(recents);
}
function setupButtons() {
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => alert("Creating chapters is part of the next stage."));
revealBtn.addEventListener("click", () => {
if (currentBook) void window.folio.revealInFolder(currentBook.path);
});
}
function setupListeners() {
window.folio.onBookOpened((data) => setCurrentBook({ path: data.path, title: data.title }));
window.folio.onBookClosed(clearBook);
window.folio.onNewBookRequested(() => void startNewBook());
window.folio.onOpenBookRequested(() => void startOpenBook());
window.folio.onOpenRecent((p) => void openPath(p));
window.folio.onRevealRequested((p) => void window.folio.revealInFolder(p));
}
document.addEventListener("DOMContentLoaded", () => {
setupButtons();
setupListeners();
void refreshRecents();
});

View file

@ -0,0 +1,85 @@
// Standalone test for the pure project/recents logic.
// Builds src/main/project.ts with esbuild (no Electron needed) and asserts behavior.
// Run with: node tests/run-project-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
const out = path.join(os.tmpdir(), `folio-project-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/main/project.ts")],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
APP_ID,
META_FILE,
CHAPTERS_DIR,
createBook,
loadBook,
saveMeta,
hasBook,
listRecents,
addRecent,
makeChapterId,
chapterFileName,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
const meta = createBook(dir, "My Novel");
ok(meta.app === APP_ID, "book app id is folio");
ok(meta.title === "My Novel", "book title set");
ok(meta.chapterOrder.length === 0, "new book has empty chapter order");
ok(fs.existsSync(path.join(dir, CHAPTERS_DIR)), "chapters/ dir created");
ok(fs.existsSync(path.join(dir, META_FILE)), "folio.json created");
const loaded = loadBook(dir);
ok(loaded.title === "My Novel", "load returns title");
ok(hasBook(dir) === true, "hasBook true for created book");
ok(hasBook("/tmp") === false, "hasBook false for non-book dir");
let threw = false;
try { loadBook("/tmp"); } catch { threw = true; }
ok(threw, "loadBook throws on non-book");
const m = loadBook(dir);
m.chapterOrder.push("x");
m.chapters["x"] = { id: "x", title: "Intro", file: "chapters/x.md" };
saveMeta(dir, m);
const m2 = loadBook(dir);
ok(m2.chapterOrder[0] === "x", "chapter order persisted");
ok(m2.chapters["x"].title === "Intro", "chapter entry persisted");
const rf = path.join(dir, "recents.json");
const r1 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-01-01T00:00:00Z" });
ok(r1.length === 1, "first recent added");
ok(listRecents(rf).length === 1, "recent read back");
const r2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-02-02T00:00:00Z" });
ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top");
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
const id = makeChapterId("Chapter One");
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");
fs.rmSync(out, { force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log("\nProject tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);