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); 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