Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/, "");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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. <hello@profullstack.com>",
Expand Down
1 change: 1 addition & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions site/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DesktopFile[]>;
/** 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;
Expand Down Expand Up @@ -140,6 +142,10 @@ async function whole(entry: DesktopFile): Promise<File> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion site/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
67 changes: 67 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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<void>((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<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 100]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n");
Expand Down
63 changes: 53 additions & 10 deletions site/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ let diskSynced = new Map<string, number>();
/** 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>(((): 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());
Expand Down Expand Up @@ -602,7 +614,7 @@ function renderDocument() {

function loadEditor(): Promise<SourceEditor> {
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;
});
Expand Down Expand Up @@ -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);
Expand All @@ -840,6 +853,29 @@ const diskSeen = new Map<string, number>();
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<void> = Promise.resolve();
const queueDisk = (work: () => Promise<void>) => (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
Expand All @@ -850,20 +886,22 @@ 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; }
});
}

/**
* A file from disk that changes underneath the editor (regenerated output, a git
* 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;
Expand All @@ -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()));
Expand All @@ -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()) {
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading