From 671bad104ba1885bddc8477c40d7a1aafefabcb4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 11:18:13 +0000 Subject: [PATCH] feat: highlight Auto-save to disk when on; nothing open shows an empty Untitled document - The Auto-save to disk toggle is drawn in the primary color while it is on. - Closing the last tab (or Close all tabs) leaves an empty Untitled.md on screen, as in any editor. It is kept nowhere (device storage, sync, disk) until something is typed; then it is a note, and Ctrl+S picks its place on disk. Left untouched, it disappears. Co-Authored-By: Claude Opus 5.5 --- desktop/package.json | 2 +- package.json | 2 +- site/assets/viewer.css | 1 + site/test/viewer.spec.ts | 19 ++++++++++++- site/viewer.ts | 60 ++++++++++++++++++++++++++++++---------- src/cli.ts | 2 +- 6 files changed, 67 insertions(+), 19 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 43e5b69..415c5a4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.30.0", + "version": "0.31.0", "private": true, "description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.", "author": "Profullstack, Inc. ", diff --git a/package.json b/package.json index cce2fbf..1889d0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.30.0", + "version": "0.31.0", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/site/assets/viewer.css b/site/assets/viewer.css index 264c38c..377b6dd 100644 --- a/site/assets/viewer.css +++ b/site/assets/viewer.css @@ -54,6 +54,7 @@ html, body.reader-app { height: 100%; overflow: hidden; } .reader-selects select { padding: 3px 5px; font-size: 11px; max-width: 132px; } .reader-app #spoilers { padding: 3px 7px; font-size: 11px; color: var(--muted); } .reader-app #spoilers[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); } +.reader-app #disk-direct[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); } #reader { flex: 1; min-height: 0; overflow: auto; padding: 27px clamp(20px, 4vw, 64px) 50px; outline-offset: -3px; } #document-content { width: fit-content; max-width: none; margin: 0; padding: 0; font: 14px/1.85 var(--mono); color: var(--fg); white-space: pre; tab-size: 2; } #editor { flex: 1; min-height: 0; display: flex; background: var(--bg); } diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 2d17891..5609b24 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -403,7 +403,24 @@ test("in the desktop app, edits go straight to disk by default, and a single tab const close = page.locator("#tabs .tab-close"); await expect(close).toBeVisible(); await close.click(); - await expect(page.locator("#document-name")).not.toHaveText("list.txt"); + // Nothing open: an empty Untitled document, kept nowhere until something is typed. + await expect(page.locator("#document-name")).toHaveText("Untitled.md"); + await expect.poll(() => editorText(page)).toBe(""); + await page.waitForTimeout(500); + await page.reload(); + await expect(page.locator('#file-list button[title="Untitled.md"]')).toHaveCount(0); + await page.locator("#tabs .tab-close").first().click(); + await expect(page.locator("#document-name")).toHaveText("Untitled.md"); + await editorInput(page).fill("# Shopping\n"); + await page.waitForTimeout(500); + await page.reload(); + await expect(page.locator('#file-list button[title="Untitled.md"]')).toHaveCount(1); + // The toggle shows when it is on. + const toggle = page.locator("#disk-direct"); + const on = await toggle.evaluate((button) => getComputedStyle(button).borderColor); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + expect(await toggle.evaluate((button) => getComputedStyle(button).borderColor)).not.toBe(on); }); test("in the desktop app, a PDF read from disk still opens after a restart, without opening its folder again", async ({ page }) => { diff --git a/site/viewer.ts b/site/viewer.ts index 4436c9f..2a35d1b 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -276,6 +276,40 @@ function notice(message: string) { function current(): Document { return workspace.documents.find((doc) => doc.path === workspace.active)!; } +/** + * With nothing else open, an empty Untitled document is on screen, as in any editor. It is + * kept nowhere (not this device's storage, not sync, not disk) until something is typed in + * it; then it is a note like any other, and Ctrl+S picks where it goes on disk. + */ +let blankPath = ""; +const isBlank = (doc: Document) => !!blankPath && doc.path === blankPath && doc.source === ""; +/** The workspace as stored and synced: the untouched blank document left out. */ +function keptWorkspace(): Workspace { + if (!blankPath) return workspace; + const documents = workspace.documents.filter((doc) => !isBlank(doc)); + if (!documents.length || documents.length === workspace.documents.length) return workspace; + const active = documents.some((doc) => doc.path === workspace.active) ? workspace.active : documents[0]!.path; + return { ...workspace, active, documents }; +} +function openBlank() { + if (!(blankPath && workspace.documents.some(isBlank))) { + const taken = new Set(workspace.documents.map((doc) => doc.path)); + let path = "Untitled.md"; + for (let n = 2; taken.has(path); n++) path = `Untitled ${n}.md`; + workspace.documents = [...workspace.documents, { path, source: "" }].sort((a, b) => comparePaths(a.path, b.path)); + blankPath = path; + } + tabs = [blankPath]; + openDocument(blankPath); + setMode(true, true); +} +/** Leaving the blank document untouched drops it. */ +function dropBlank(next: string) { + if (!blankPath || next === blankPath) return; + if (workspace.documents.some(isBlank)) workspace.documents = workspace.documents.filter((doc) => !isBlank(doc)); + blankPath = ""; +} + async function save() { if (pasteDocument) return; if (cloudDocument) { await saveCloud(); return; } @@ -283,7 +317,7 @@ async function save() { const savingRevision = revision; pendingSave = true; try { - await persist(workspace); + await persist(keptWorkspace()); if (revision === savingRevision) { // Every saved edit reaches the account a few seconds later, unless it came from sync. if (!applyingSync) scheduleSync(); @@ -373,7 +407,7 @@ function renderTabs() { close.className = "tab-close"; close.textContent = "×"; close.setAttribute("aria-label", `Close ${path}`); - close.disabled = workspace.documents.length < 2; + close.disabled = path === blankPath && tabs.length === 1 && workspace.documents.some(isBlank); close.onclick = () => closeTab(path); tab.append(open, close); return tab; @@ -386,11 +420,10 @@ function renderTabs() { function closeTab(path: string) { // The last tab gives way to another file: one file is always on screen. + // The last tab closes to an empty Untitled document, unless that is what is closing. if (tabs.length < 2) { - const next = workspace.documents.find((doc) => doc.path !== path)?.path; - if (!next) return; - tabs = [next]; - openDocument(next); + if (path === blankPath && workspace.documents.some(isBlank)) return; + openBlank(); return; } const index = tabs.indexOf(path); @@ -404,12 +437,8 @@ function closeOtherTabs(path: string) { if (path === workspace.active) renderTabs(); else openDocument(path); } -/** Every tab closes; one file is always on screen, so the first in the file tree takes its place. */ -function closeAllTabs() { - const first = workspace.documents[0]!.path; - tabs = [first]; - if (first === workspace.active) renderTabs(); else openDocument(first); -} +/** Every tab closes, and an empty Untitled document takes their place. */ +function closeAllTabs() { openBlank(); } /** Right-click on a tab: reload it from disk, or close it, the others, or all of them. */ const tabMenu = element("tab-menu"); @@ -426,9 +455,9 @@ function showTabMenu(path: string, x: number, y: number) { tabMenu.replaceChildren( item("Reload from disk", () => void reloadFromDisk(path)), document.createElement("hr"), - item("Close tab", () => closeTab(path), workspace.documents.length < 2), + item("Close tab", () => closeTab(path), path === blankPath && tabs.length === 1), item("Close all but this tab", () => closeOtherTabs(path), tabs.length < 2), - item("Close all tabs", closeAllTabs, tabs.length < 2 && tabs[0] === workspace.documents[0]?.path), + item("Close all tabs", closeAllTabs, path === blankPath && tabs.length === 1), ); tabMenu.hidden = false; const { width, height } = tabMenu.getBoundingClientRect(); @@ -791,6 +820,7 @@ function documentInfo() { } function openDocument(path: string, focus = true) { + dropBlank(path); workspace.active = path; element("document-name").textContent = path; document.title = `${path.split("/").at(-1)} — readm3`; @@ -1643,7 +1673,7 @@ async function start() { void setupPwa(); setupOpenInDesktop(); startAutoSync({ - read: () => ({ name: workspace.name, active: workspace.active, documents: workspace.documents }), + read: () => { const kept = keptWorkspace(); return { name: kept.name, active: kept.active, documents: kept.documents }; }, settings: () => ({ theme: theme.value, flavor: flavor.value as Flavor }), apply: (incoming, settings) => applySynced(incoming as Workspace, settings), status: (text) => { element("sync-note").textContent = text; if (text.startsWith("Synced")) updateUsage(true); }, diff --git a/src/cli.ts b/src/cli.ts index c7f2f35..cdac567 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.30.0"; +export const VERSION = "0.31.0"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code