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