From ddd1162117502c550bad2f940332380d6faa589c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 13:14:04 +0000 Subject: [PATCH] Desktop: files from disk are still watched after a restart (v0.25.2) A desktop file's handle cannot be stored, so after relaunching the app every file it had opened from disk lost its disk link and the stored copy won: ~/Desktop/list.txt saved in Sublime stayed old in readm3 and never synced. The desktop app (0.19.0) now keeps the paths it opened in userData and hands back the ones still there (reopen-files); the page stores each desktop file's path and last-seen fingerprint, restores the handles on launch and checks every file at once. Co-Authored-By: Claude Opus 5.5 --- desktop/main.cjs | 41 ++++++++++++++++++++++++++++++++++++++-- desktop/package.json | 2 +- desktop/preload.cjs | 1 + package.json | 2 +- site/disk.ts | 34 +++++++++++++++++++++++++++++++-- site/test/viewer.spec.ts | 39 ++++++++++++++++++++++++++++++++++++++ site/viewer.ts | 2 ++ src/cli.ts | 2 +- 8 files changed, 116 insertions(+), 7 deletions(-) diff --git a/desktop/main.cjs b/desktop/main.cjs index 1193eee..944d836 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -39,6 +39,27 @@ app.setName("readm3"); /** Paths this process opened for the page; the only paths it may write. */ const opened = new Set(); +/** + * The paths opened before, kept in userData so a restart can watch them again: without it + * the page's copy of a file saved in another editor while readm3 was closed (or after a + * restart) never reloads. Only paths the person opened here are ever written to it. + */ +const KEEP_OPENED = 200_000; +let openedFile; +let keeping; +function remember(path) { + opened.delete(path); + opened.add(path); + keep(); +} +function keep() { + if (!openedFile || keeping) return; + keeping = setTimeout(() => { + keeping = undefined; + const kept = [...opened].slice(-KEEP_OPENED); + void writeFile(openedFile, JSON.stringify(kept)).catch(() => { /* Watched this session only. */ }); + }, 1000); +} /** Files waiting for the page to ask for them (it asks once it has loaded). */ let pending = []; let window; @@ -54,7 +75,7 @@ async function load(paths, cwd = process.cwd()) { const add = async (path, info, relative) => { if (info.size > MAX_FILE_BYTES || files.length >= MAX_FILES) return; files.push({ path, name: basename(path), relative, size: info.size }); - opened.add(path); + remember(path); }; const walk = async (directory, prefix) => { for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -225,6 +246,10 @@ else { // macOS hands files over as events (Finder, `open -a readm3`), before and after launch. app.on("open-file", (event, path) => { event.preventDefault(); void openPaths([path]); }); app.whenReady().then(async () => { + 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. */ } + keep(); // A file macOS handed over before this point. protocol.handle("app", (request) => { const { pathname } = new URL(request.url); const path = pathname === "/viewer" || pathname === "/viewer/" ? "/viewer/index.html" : decodeURIComponent(pathname); @@ -252,6 +277,18 @@ else { const info = await stat(path); return { size: info.size, modified: info.mtimeMs }; }); + // After a restart: which of the files the page had from disk are still there, with their size. + ipcMain.handle("reopen-files", async (event, paths) => { + if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); + if (!Array.isArray(paths)) throw new Error("Invalid paths."); + const files = []; + for (const path of paths) { + if (typeof path !== "string" || !opened.has(path)) continue; + try { const info = await stat(path); if (info.isFile()) files.push({ path, name: basename(path), size: info.size }); } + catch { /* Moved or deleted: the page keeps its copy. */ } + } + return files; + }); // A large file reaches the page a range at a time, never whole through one message. ipcMain.handle("read-range", async (event, path, start, end) => { if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); @@ -280,7 +317,7 @@ else { if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed."); const { canceled, filePath } = await dialog.showSaveDialog(window, { defaultPath: typeof name === "string" ? basename(name) : undefined }); if (canceled || !filePath) return null; - opened.add(filePath); + remember(filePath); return { path: filePath, name: basename(filePath) }; }); // A page picker that lands on a sensitive folder waits for this answer, forever if no one gives it. diff --git a/desktop/package.json b/desktop/package.json index 293f6fa..b651fb1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.18.0", + "version": "0.19.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 e2277c1..ded6ee2 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -15,6 +15,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o 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), + reopen: (paths) => ipcRenderer.invoke("reopen-files", paths), writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data), pick: (folder) => ipcRenderer.invoke("pick", folder), saveAs: (name) => ipcRenderer.invoke("save-as", name), diff --git a/package.json b/package.json index d694f5d..c534802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.25.1", + "version": "0.25.2", "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 11b266c..9f339fe 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -99,6 +99,8 @@ interface DesktopBridge { saveAs?(name: string): Promise<{ path: string; name: string } | null>; /** Absent in desktop builds before 0.18.0: a large file's size and time without reading it. */ 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; } /** Present only inside the readm3 desktop app (desktop/preload.cjs). */ export const desktop = typeof window === "undefined" ? undefined : (window as unknown as { readm3Desktop?: DesktopBridge }).readm3Desktop; @@ -153,6 +155,7 @@ function desktopHandle(entry: DesktopFile): FileSystemFileHandle { return new File([data.buffer as ArrayBuffer], entry.name, { lastModified: modified }); }, stat: desktop!.stat ? () => desktop!.stat!(entry.path) : undefined, + desktopFile: entry, createWritable: async () => { const parts: BlobPart[] = []; return { write: async (data: BlobPart) => { parts.push(data); }, close: async () => { await desktop!.writeFile(entry.path, new Uint8Array(await new Blob(parts).arrayBuffer())); } }; @@ -196,9 +199,28 @@ export async function saveAs(name: string): Promise } const KEY = "disk"; +/** + * A desktop file's path per workspace path, since its handle cannot be stored. After a + * restart the desktop app (0.19.0 on) hands back the ones it opened before, so a file saved + * in another editor while readm3 was closed is still read from disk, not from the old copy. + */ +const DESKTOP_KEY = "disk-desktop"; +type KeptDesktopFile = { path: string; name: string; relative?: string; saved: number }; export async function loadEntries(): Promise> { - try { return new Map(Object.entries(await readKey>(KEY) ?? {})); } - catch { return new Map(); } + let entries: Map; + try { entries = new Map(Object.entries(await readKey>(KEY) ?? {})); } + catch { entries = new Map(); } + if (!desktop?.reopen) return entries; + try { + const kept = Object.entries(await readKey>(DESKTOP_KEY) ?? {}); + if (!kept.length) return entries; + const found = new Map((await desktop.reopen(kept.map(([, file]) => file.path))).map((file) => [file.path, file])); + for (const [path, file] of kept) { + const now = found.get(file.path); + if (now && !entries.has(path)) entries.set(path, { handle: desktopHandle({ path: file.path, name: file.name, relative: file.relative, size: now.size }), saved: file.saved }); + } + } catch { /* An older desktop build, or an unreadable list: the copies stay as they are. */ } + return entries; } /** * What each path last matched on disk, kept apart from the handles because a desktop @@ -215,6 +237,14 @@ export async function storeEntries(entries: Map): Promise typeof FileSystemHandle !== "undefined" && entry.handle instanceof FileSystemHandle); try { await writeKey(KEY, Object.fromEntries(storable)); } catch { /* Handles last for this session only. */ } + if (desktop) { + const kept: Record = {}; + for (const [path, entry] of entries) { + const file = (entry.handle as FileSystemFileHandle & { desktopFile?: DesktopFile }).desktopFile; + if (file) kept[path] = { path: file.path, name: file.name, relative: file.relative, saved: entry.saved }; + } + try { await writeKey(DESKTOP_KEY, kept); } catch { /* As above. */ } + } for (const [path, entry] of entries) synced.set(path, entry.saved); try { await writeKey(SYNCED, Object.fromEntries(synced)); } catch { /* As above. */ } } diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 0a8c727..2c4f247 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -197,6 +197,45 @@ test("in the desktop app, a folder comes as a list and large files and images ar expect(await keptBlobs()).toBe(0); }); +test("in the desktop app, a file saved in another editor while readm3 was closed reloads on the next launch", async ({ page }) => { + // The fake disk outlives a reload, as ~/Desktop/list.txt outlives quitting the app. + 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") ?? "[]")); + Object.assign(window, { + readm3Desktop: { + takeLaunchFiles: async () => [], + onOpenFiles: () => {}, + 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"); + await page.locator('#file-list button[title="Welcome.md"]').click(); + // Quit, save the file in Sublime, launch again: the stored copy must not win. + await page.evaluate(() => localStorage.setItem("test:disk", JSON.stringify({ text: "milk\neggs\n", modified: 2 }))); + await page.reload(); + await page.locator('#file-list button[title="list.txt"]').click(); + await expect.poll(() => editorText(page)).toContain("eggs"); + await expect(page.locator("#save-status")).toHaveText("Saved to disk"); +}); + test("keeps a rolling history: opened, saved, changed on disk, with snippets and restore", async ({ page }) => { // A fake disk whose file can change under the editor, as a regenerated output.json does. await page.addInitScript(() => { diff --git a/site/viewer.ts b/site/viewer.ts index ee41fd7..f3f93b0 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -1358,6 +1358,8 @@ async function start() { const remote = params.get("url")?.trim(); if (remote) await openUrlParam(remote); receiveLaunches(); + // 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); window.addEventListener("focus", () => void checkDisk(true)); const topup = params.get("topup"); diff --git a/src/cli.ts b/src/cli.ts index d0f3ee5..6d3c17e 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.25.1"; +export const VERSION = "0.25.2"; /** 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