From f681ed5a06b85d522531694730d37bf4e4d6f741 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 09:51:21 +0000 Subject: [PATCH 1/2] fix(desktop): files saved in another editor show at once, even over a synced copy The desktop app now watches the folders of the files it opened and tells the page which file changed, so a save in Sublime reaches readm3 immediately instead of on a 5 s poll that stops while the window is hidden. A disk file whose copy came from sync (another device's version) counted as 'unsaved edits', so every later save on disk was ignored for good. Only typing in readm3 now holds the disk back; a sync copy is replaced by the disk version and kept in History. Co-Authored-By: Claude Opus 5.5 --- desktop/main.cjs | 48 +++++++++++++++++++++++++++- desktop/package.json | 2 +- desktop/preload.cjs | 1 + package.json | 2 +- site/disk.ts | 6 ++++ site/history.ts | 2 +- site/test/viewer.spec.ts | 67 ++++++++++++++++++++++++++++++++++++++++ site/viewer.ts | 63 +++++++++++++++++++++++++++++++------ 8 files changed, 177 insertions(+), 14 deletions(-) diff --git a/desktop/main.cjs b/desktop/main.cjs index 944d836..8914e33 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -18,8 +18,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, net, protocol, session, shell } = require("electron"); const { createHash, randomBytes } = require("node:crypto"); const { createServer } = require("node:http"); +const { watch } = require("node:fs"); const { open, readFile, readdir, writeFile, stat } = require("node:fs/promises"); -const { basename, join, resolve } = require("node:path"); +const { basename, dirname, join, resolve } = require("node:path"); const { fileURLToPath, pathToFileURL } = require("node:url"); const ORIGIN = (process.env.READM3_URL || "https://readm3.com").replace(/\/$/, ""); @@ -51,6 +52,49 @@ function remember(path) { opened.delete(path); opened.add(path); keep(); + watchFolder(dirname(path)); +} + +/** + * Opened files are watched, so a save in another editor (Sublime, vim, a build) reaches the + * page at once instead of on its next poll. The folder is watched, not the file: an editor + * that saves by writing a new file and renaming it over the old one would end a watch on + * the file itself. inotify watches are a per-user budget shared with every other program, + * so at most MAX_WATCHED folders are watched, the most recently opened first; the page's + * poll still covers the rest. + */ +const MAX_WATCHED = 2000; +const watchers = new Map(); +/** Paths with a change waiting to be sent, so one save (often several events) sends once. */ +const changes = new Set(); +let flushing; +function watchFolder(folder) { + const existing = watchers.get(folder); + if (existing) { watchers.delete(folder); watchers.set(folder, existing); return; } + let watcher; + try { + watcher = watch(folder, { persistent: false }, (_event, name) => { + if (!name) return; + const path = join(folder, name.toString()); + if (!opened.has(path)) return; + changes.add(path); + // Sent once the events stop: a save is often a truncate and several writes. + clearTimeout(flushing); + flushing = setTimeout(() => { + flushing = undefined; + const paths = [...changes]; + changes.clear(); + if (window && !window.isDestroyed()) window.webContents.send("files-changed", paths); + }, 60); + }); + } catch { return; /* Gone, unreadable, or out of watches: the page's poll covers it. */ } + watcher.on("error", () => { watcher.close(); if (watchers.get(folder) === watcher) watchers.delete(folder); }); + watchers.set(folder, watcher); + if (watchers.size > MAX_WATCHED) { + const [oldest, old] = watchers.entries().next().value; + old.close(); + watchers.delete(oldest); + } } function keep() { if (!openedFile || keeping) return; @@ -249,6 +293,8 @@ else { openedFile = join(app.getPath("userData"), "opened.json"); try { for (const path of JSON.parse(await readFile(openedFile, "utf8"))) if (typeof path === "string") opened.add(path); } catch { /* First launch, or an unreadable list: files are watched once opened again. */ } + // The folders of the most recently opened files are watched again, newest last so they stay. + for (const path of [...opened].slice(-MAX_WATCHED * 4)) watchFolder(dirname(path)); keep(); // A file macOS handed over before this point. protocol.handle("app", (request) => { const { pathname } = new URL(request.url); diff --git a/desktop/package.json b/desktop/package.json index b651fb1..b0ac1eb 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.19.0", + "version": "0.20.0", "private": true, "description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.", "author": "Profullstack, Inc. ", diff --git a/desktop/preload.cjs b/desktop/preload.cjs index ded6ee2..78a0cfa 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -12,6 +12,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o takeLaunchFiles: () => ipcRenderer.invoke("launch-files"), signInWithBrowser: () => ipcRenderer.invoke("sign-in-with-browser"), onOpenFiles: (callback) => { ipcRenderer.on("open-files", (_event, files) => callback(files)); }, + onFilesChanged: (callback) => { ipcRenderer.on("files-changed", (_event, paths) => callback(paths)); }, readFile: (path) => ipcRenderer.invoke("read-file", path), readRange: (path, start, end) => ipcRenderer.invoke("read-range", path, start, end), stat: (path) => ipcRenderer.invoke("stat-file", path), diff --git a/package.json b/package.json index 5f95859..3f5a026 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.27.1", + "version": "0.28.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/disk.ts b/site/disk.ts index 9f339fe..5b0b075 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -101,6 +101,8 @@ interface DesktopBridge { stat?(path: string): Promise<{ size: number; modified: number }>; /** Absent in desktop builds before 0.19.0: which files opened in an earlier run are still there. */ reopen?(paths: string[]): Promise; + /** Absent in desktop builds before 0.20.0: disk paths of opened files that just changed on disk. */ + onFilesChanged?(callback: (paths: string[]) => void): void; } /** Present only inside the readm3 desktop app (desktop/preload.cjs). */ export const desktop = typeof window === "undefined" ? undefined : (window as unknown as { readm3Desktop?: DesktopBridge }).readm3Desktop; @@ -140,6 +142,10 @@ async function whole(entry: DesktopFile): Promise { return new File(parts, entry.name); } +/** The disk path behind a desktop handle; undefined for a browser handle. */ +export const desktopPath = (handle: FileSystemFileHandle): string | undefined => + (handle as FileSystemFileHandle & { desktopFile?: DesktopFile }).desktopFile?.path; + /** A handle over a path the desktop app may read and write; a Save As path has no size or bytes yet. */ function desktopHandle(entry: DesktopFile): FileSystemFileHandle { return { diff --git a/site/history.ts b/site/history.ts index 85d2804..141b4f8 100644 --- a/site/history.ts +++ b/site/history.ts @@ -7,7 +7,7 @@ import { readKey, writeKey } from "./workspace.ts"; * 8 MB of them per file; the oldest go first. Nothing here leaves the device except * the two versions sent when someone asks for an AI summary. */ -export type Reason = "opened" | "saved" | "changed on disk" | "restored"; +export type Reason = "opened" | "saved" | "changed on disk" | "replaced by disk" | "restored"; export interface Summary { text: string; costMicros: number; arm: string; at: string; } export interface Snapshot { id: string; at: string; reason: Reason; source: string; summary?: Summary; } diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 24856cf..c97eaa9 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -236,6 +236,73 @@ test("in the desktop app, a file saved in another editor while readm3 was closed await expect(page.locator("#save-status")).toHaveText("Saved to disk"); }); +test("in the desktop app, a save in another editor shows at once, even over a copy sync brought", async ({ page }) => { + await page.addInitScript(() => { + const bytes = (text: string) => new TextEncoder().encode(text); + const file = () => JSON.parse(localStorage.getItem("test:disk") ?? '{"text":"milk\\n","modified":1}') as { text: string; modified: number }; + const opened = new Set(JSON.parse(localStorage.getItem("test:opened") ?? "[]")); + const listeners: Array<(paths: string[]) => void> = []; + Object.assign(window, { + // What main.cjs does when Sublime writes the file: the watcher names it. + sublimeSave: (text: string) => { + localStorage.setItem("test:disk", JSON.stringify({ text, modified: file().modified + 1 })); + for (const listener of listeners) listener(["/home/me/Desktop/list.txt"]); + }, + readm3Desktop: { + takeLaunchFiles: async () => [], + onOpenFiles: () => {}, + onFilesChanged: (callback: (paths: string[]) => void) => { listeners.push(callback); }, + pick: async () => { + opened.add("/home/me/Desktop/list.txt"); + localStorage.setItem("test:opened", JSON.stringify([...opened])); + return [{ path: "/home/me/Desktop/list.txt", name: "list.txt", size: file().text.length }]; + }, + reopen: async (paths: string[]) => paths.filter((path) => opened.has(path)).map((path) => ({ path, name: "list.txt", size: file().text.length })), + readFile: async (path: string) => { + if (!opened.has(path)) throw new Error("readm3 can only read files it opened."); + return { data: bytes(file().text), modified: file().modified }; + }, + stat: async () => ({ size: file().text.length, modified: file().modified }), + readRange: async () => new Uint8Array(), + writeFile: async () => {}, + }, + }); + }); + await page.reload(); + await page.locator("#open-files").click(); + await expect(page.locator("#document-name")).toHaveText("list.txt"); + await expect.poll(() => editorText(page)).toContain("milk"); + // A save in Sublime appears without the window being focused or polled. + await page.evaluate(() => (window as unknown as { sublimeSave(text: string): void }).sublimeSave("milk\nbread\n")); + await expect.poll(() => editorText(page), { timeout: 2000 }).toContain("bread"); + // Sync applies another device's copy of the file here: it differs from disk, but nobody typed it. + await page.evaluate(() => new Promise((done, fail) => { + const request = indexedDB.open("readm3", 1); + request.onsuccess = () => { + const store = request.result.transaction("workspace", "readwrite").objectStore("workspace"); + const get = store.get("current"); + get.onsuccess = () => { + const workspace = get.result as { documents: Array<{ path: string; source: string }> }; + workspace.documents.find((doc) => doc.path === "list.txt")!.source = "milk\nfrom the phone\n"; + store.put(workspace, "current").onsuccess = () => done(); + }; + get.onerror = () => fail(get.error); + }; + })); + await page.reload(); + await page.locator('#file-list button[title="list.txt"]').click(); + await expect.poll(() => editorText(page)).toContain("from the phone"); + // The next Sublime save wins over that copy, which stays in History. + await page.evaluate(() => (window as unknown as { sublimeSave(text: string): void }).sublimeSave("milk\nbread\neggs\n")); + await expect.poll(() => editorText(page), { timeout: 2000 }).toContain("eggs"); + await expect(page.locator("#save-status")).toHaveText("Saved to disk"); + // Typing here is different: a save elsewhere does not replace unsaved typing. + await editorInput(page).fill("milk\nbread\neggs\nbutter\n"); + await page.evaluate(() => (window as unknown as { sublimeSave(text: string): void }).sublimeSave("milk\n")); + await expect(page.locator("#notice")).toContainText("Your unsaved edits are kept"); + await expect.poll(() => editorText(page)).toContain("butter"); +}); + test("in the desktop app, a PDF read from disk still opens after a restart, without opening its folder again", async ({ page }) => { await page.addInitScript(() => { const pdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\ntrailer<>\n%%EOF\n"); diff --git a/site/viewer.ts b/site/viewer.ts index 20d8017..262f88e 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -74,6 +74,18 @@ let diskSynced = new Map(); /** A copy that holds nothing the disk lacks: the same text, or unedited since it was last read or saved. */ const matchesDisk = (doc: Document) => diskEntries.has(doc.path) ? !unsavedOnDisk(doc) : diskSynced.get(doc.path) === disk.fingerprint(doc.source); const unsavedOnDisk = (doc: Document) => { const entry = diskEntries.get(doc.path); return !!entry && disk.fingerprint(doc.source) !== entry.saved; }; +/** + * Disk files typed in here since they were last read from or written to disk. Only these + * hold edits a change on disk must not replace: a copy that differs from disk because sync + * brought another device's version is not an edit, and a save in another editor wins over it + * (both versions stay in History). Kept across restarts so unsaved typing survives one. + */ +const typedHere = new Set(((): string[] => { try { return JSON.parse(localStorage.getItem("readm3:typed") ?? "[]"); } catch { return []; } })()); +function typed(path: string, on = true) { + if (on === typedHere.has(path)) return; + if (on) typedHere.add(path); else typedHere.delete(path); + try { localStorage.setItem("readm3:typed", JSON.stringify([...typedHere])); } catch { /* This session only. */ } +} let mediaUrl: string | null = null; /** What the active document is on screen: Markdown, code in some language, or a PDF or image. */ const kind = () => kindOf(current()); @@ -602,7 +614,7 @@ function renderDocument() { function loadEditor(): Promise { return editorLoading ??= import("./code-editor.ts").then(({ createEditor }) => { - sourceEditor = createEditor(editor, (text) => { current().source = text; documentInfo(); changed(); }); + sourceEditor = createEditor(editor, (text) => { current().source = text; if (diskEntries.has(workspace.active)) typed(workspace.active); documentInfo(); changed(); }); sourceEditor.view.scrollDOM.addEventListener("scroll", progress); return sourceEditor; }); @@ -825,6 +837,7 @@ function openHistory() { void showHistory(path, (source) => { if (workspace.active !== path) return; current().source = source; + if (diskEntries.has(path)) typed(path); previousRender = ""; documentInfo(); setMode(editing, false); @@ -840,6 +853,29 @@ const diskSeen = new Map(); let sweepFrom = 0; let checking = false; const SWEEP_BATCH = 100; +/** One disk check at a time, in order: a poll round and a change the desktop reported never overlap. */ +let diskWork: Promise = Promise.resolve(); +const queueDisk = (work: () => Promise) => (diskWork = diskWork.then(work).catch(() => { /* The next change or poll tries again. */ })); + +/** + * The desktop app (0.20.0 on) watches the files it opened and names the ones that change, + * so a save in Sublime or vim shows here at once, even with this window in the background. + */ +function watchDesktopFiles() { + if (!disk.desktop?.onFilesChanged) return; + disk.desktop.onFilesChanged((paths) => { + const wanted = new Set(paths); + const changedHere: string[] = []; + for (const [path, entry] of diskEntries) { + const onDisk = disk.desktopPath(entry.handle); + if (onDisk && wanted.has(onDisk)) changedHere.push(path); + } + if (!changedHere.length || !workspace || cloudDocument || pasteDocument) return; + // The active file first, so what is on screen updates before the rest. + changedHere.sort((a, b) => Number(b === workspace.active) - Number(a === workspace.active)); + void queueDisk(async () => { for (const path of changedHere) await checkDiskFile(path, true); }); + }); +} /** * Every file from disk is watched, not only the open one: a file saved in another editor @@ -850,12 +886,14 @@ const SWEEP_BATCH = 100; async function checkDisk(all = false) { if (!workspace || cloudDocument || pasteDocument || document.hidden || checking) return; checking = true; - try { - const others = [...diskEntries.keys()].filter((path) => path !== workspace.active); - const batch = all ? others : others.slice(sweepFrom, sweepFrom + SWEEP_BATCH); - sweepFrom = all || sweepFrom + SWEEP_BATCH >= others.length ? 0 : sweepFrom + SWEEP_BATCH; - for (const path of [workspace.active, ...batch]) await checkDiskFile(path); - } finally { checking = false; } + await queueDisk(async () => { + try { + const others = [...diskEntries.keys()].filter((path) => path !== workspace.active); + const batch = all ? others : others.slice(sweepFrom, sweepFrom + SWEEP_BATCH); + sweepFrom = all || sweepFrom + SWEEP_BATCH >= others.length ? 0 : sweepFrom + SWEEP_BATCH; + for (const path of [workspace.active, ...batch]) await checkDiskFile(path); + } finally { checking = false; } + }); } /** @@ -863,7 +901,7 @@ async function checkDisk(all = false) { * checkout, another editor) gets a version in History. With no local edits it is * reloaded; with local edits it is left alone and the disk version waits in History. */ -async function checkDiskFile(path: string) { +async function checkDiskFile(path: string, reported = false) { const doc = workspace.documents.find((candidate) => candidate.path === path); const entry = diskEntries.get(path); if (!doc || !entry) return; @@ -885,7 +923,7 @@ async function checkDiskFile(path: string) { } let file: File; try { file = await entry.handle.getFile(); } catch { return; } - if (diskSeen.get(path) === file.lastModified) return; + if (!reported && diskSeen.get(path) === file.lastModified) return; diskSeen.set(path, file.lastModified); if (binaryType(path)) { const source = toBase64(new Uint8Array(await file.arrayBuffer())); @@ -899,10 +937,13 @@ async function checkDiskFile(path: string) { } const text = await file.text(); if (disk.fingerprint(text) === entry.saved) return; - const edited = unsavedOnDisk(doc); + // Only typing here holds the disk back; a copy sync brought is replaced, and kept in History. + const edited = unsavedOnDisk(doc) && typedHere.has(path); await fileHistory.record(path, text, "changed on disk"); if (edited) { if (active()) notice(`${name} changed on disk. Your unsaved edits are kept; the disk version is in History.`); return; } + if (doc.source !== text && unsavedOnDisk(doc)) await fileHistory.record(path, doc.source, "replaced by disk"); entry.saved = disk.fingerprint(text); + typed(path, false); void disk.storeEntries(diskEntries); doc.source = text; if (active()) { @@ -945,6 +986,7 @@ async function saveFile(saveAs = false) { if (!handle) return; await disk.write(handle, isBinary() ? bytesOf(doc) : doc.source); diskEntries.set(doc.path, { handle, saved: disk.fingerprint(doc.source) }); + typed(doc.path, false); await disk.storeEntries(diskEntries); markUnsaved(); saveStatus.textContent = `Saved to disk · ${handle.name}`; @@ -1396,6 +1438,7 @@ async function start() { const remote = params.get("url")?.trim(); if (remote) await openUrlParam(remote); receiveLaunches(); + watchDesktopFiles(); // Files from disk changed while readm3 was closed are picked up now, not one round at a time. void checkDisk(true); setInterval(() => void checkDisk(), 5000); From bdc3b588ecc57934a609f2a18c91cbf9599f2543 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 09:51:31 +0000 Subject: [PATCH 2/2] chore: cli VERSION 0.28.0 Co-Authored-By: Claude Opus 5.5 --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 506aa8c..9d6f80a 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.27.1"; +export const VERSION = "0.28.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