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
41 changes: 39 additions & 2 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 })) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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.
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.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. <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 @@ -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),
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.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",
Expand Down
34 changes: 32 additions & 2 deletions site/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DesktopFile[]>;
}
/** 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 @@ -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())); } };
Expand Down Expand Up @@ -196,9 +199,28 @@ export async function saveAs(name: string): Promise<FileSystemFileHandle | null>
}

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<Map<string, DiskEntry>> {
try { return new Map(Object.entries(await readKey<Record<string, DiskEntry>>(KEY) ?? {})); }
catch { return new Map(); }
let entries: Map<string, DiskEntry>;
try { entries = new Map(Object.entries(await readKey<Record<string, DiskEntry>>(KEY) ?? {})); }
catch { entries = new Map(); }
if (!desktop?.reopen) return entries;
try {
const kept = Object.entries(await readKey<Record<string, KeptDesktopFile>>(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
Expand All @@ -215,6 +237,14 @@ export async function storeEntries(entries: Map<string, DiskEntry>): Promise<voi
// Only real handles can be stored; one desktop handle in the map would fail the whole write.
const storable = [...entries].filter(([, entry]) => 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<string, KeptDesktopFile> = {};
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. */ }
}
39 changes: 39 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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(() => {
Expand Down
2 changes: 2 additions & 0 deletions site/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
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.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
Expand Down
Loading