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
57 changes: 45 additions & 12 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -339,23 +339,56 @@ else {
return files;
});
// A workspace file that lost its link to disk (opened before 0.19.0, or it came from sync):
// the opened paths that end in its workspace path, so the page can read it from disk again.
ipcMain.handle("find-opened", async (event, relatives) => {
// found among the paths opened before, else in the home folder, so the page reads it from
// disk again without asking. Several matches: the most recently changed one.
ipcMain.handle("find-opened", async (event, relatives, search = false) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
if (!Array.isArray(relatives)) throw new Error("Invalid paths.");
const byName = new Map();
for (const path of opened) { const name = basename(path); const list = byName.get(name); if (list) list.push(path); else byName.set(name, [path]); }
const wanted = relatives.filter((relative) => typeof relative === "string" && relative && !relative.split("/").includes("..") && !NEVER.test(relative));
const found = {};
for (const relative of relatives) {
if (typeof relative !== "string" || !relative) continue;
const candidates = (byName.get(basename(relative)) ?? []).filter((path) => path === relative || path.endsWith(`/${relative}`) || path.endsWith(`\\${relative.replaceAll("/", "\\")}`));
const files = [];
for (const path of candidates) {
try { const info = await stat(path); if (info.isFile()) files.push({ path, name: basename(path), size: info.size }); }
const pick = async (relative, paths) => {
let best;
for (const path of new Set(paths)) {
try { const info = await stat(path); if (info.isFile() && info.size <= MAX_FILE_BYTES && (!best || info.mtimeMs > best.modified)) best = { path, name: basename(path), size: info.size, modified: info.mtimeMs }; }
catch { /* Moved or deleted. */ }
}
if (files.length) found[relative] = files;
}
if (!best) return false;
remember(best.path);
found[relative] = [{ path: best.path, name: best.name, size: best.size }];
return true;
};
const matches = (path, relative) => path === relative || path.endsWith(`/${relative}`) || path.endsWith(`\\${relative.replaceAll("/", "\\")}`);
const byName = new Map();
for (const path of opened) { const name = basename(path); const list = byName.get(name); if (list) list.push(path); else byName.set(name, [path]); }
let missing = [];
for (const relative of wanted) if (!await pick(relative, (byName.get(basename(relative)) ?? []).filter((path) => matches(path, relative)))) missing.push(relative);
// Searching is asked for only by Reload on one file: a note made in readm3 must never be
// tied to some unrelated file of the same name.
if (!missing.length || search !== true) return found;
// The usual places first: ~/Desktop/notes.md opened as "notes.md" or "Desktop/notes.md".
const home = app.getPath("home");
const roots = [home, ...["desktop", "documents", "downloads"].map((name) => { try { return app.getPath(name); } catch { return undefined; } }).filter(Boolean), join(home, "src"), join(home, "notes")];
const still = [];
for (const relative of missing) if (!await pick(relative, roots.map((root) => join(root, relative)))) still.push(relative);
missing = still;
if (!missing.length) return found;
// Then a bounded walk of the home folder, for a file opened from deeper in it.
const names = new Map(missing.map((relative) => [basename(relative), []]));
const deadline = Date.now() + 4000;
let folders = 0;
const walk = async (directory, depth) => {
if (depth > 6 || folders++ > 50_000 || Date.now() > deadline) return;
let entries;
try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) await walk(path, depth + 1);
else if (entry.isFile()) names.get(entry.name)?.push(path);
}
};
await walk(home, 0);
for (const relative of missing) await pick(relative, (names.get(basename(relative)) ?? []).filter((path) => matches(path, relative)));
return found;
});
// "Where is this file?": the person points at it in the system dialog, and it is linked again.
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.21.0",
"version": "0.30.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
2 changes: 1 addition & 1 deletion desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && o
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),
findOpened: (relatives) => ipcRenderer.invoke("find-opened", relatives),
findOpened: (relatives, search) => ipcRenderer.invoke("find-opened", relatives, search),
locate: (name) => ipcRenderer.invoke("locate", name),
writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data),
pick: (folder) => ipcRenderer.invoke("pick", folder),
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.29.0",
"version": "0.30.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
1 change: 1 addition & 0 deletions site/assets/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<label id="flavor-label">Flavor <select id="flavor"><option value="github">GitHub</option><option value="commonmark">CommonMark</option><option value="reddit">Reddit</option><option value="all">All</option></select></label>
<label>Theme <select id="theme">__THEMES__</select></label>
<button id="spoilers" aria-pressed="false" title="Reveal spoilers (s)">Spoilers</button>
<button id="disk-direct" aria-pressed="true" title="Write edits to the file on disk as you type, and read files from disk when opened">Auto-save to disk</button>
</div>
</div>
<div id="reader" tabindex="0" role="region" aria-label="Document"><pre id="document-content" class="doc"></pre><div id="code-content" class="code-view" hidden></div><div id="media-content" class="media-view" hidden></div></div>
Expand Down
6 changes: 3 additions & 3 deletions site/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ interface DesktopBridge {
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;
/** Absent before 0.21.0: opened disk files whose path ends in each workspace path. */
findOpened?(relatives: string[]): Promise<Record<string, DesktopFile[]>>;
/** Absent before 0.21.0: the person picks where a workspace file is on disk. */
/** Absent before 0.29.0: opened disk files whose path ends in each workspace path; `search` also looks through the home folder. */
findOpened?(relatives: string[], search?: boolean): Promise<Record<string, DesktopFile[]>>;
/** Absent before 0.29.0: the person picks where a workspace file is on disk. */
locate?(name: string): Promise<DesktopFile | null>;
}
/** Present only inside the readm3 desktop app (desktop/preload.cjs). */
Expand Down
44 changes: 44 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ test("imports safely, edits, downloads exact source, and restores the draft afte
test("opens a folder from disk, marks unsaved files, and writes them back with Ctrl+S", async ({ page }) => {
// Fake File System Access handles: the real pickers cannot be driven headless.
await page.addInitScript(() => {
// Auto-save to disk off: edits wait for Ctrl+S (the optional keep-a-copy way).
localStorage.setItem("readm3:disk-direct", "0");
const written: Record<string, string> = {};
(window as unknown as { written: typeof written }).written = written;
const file = (name: string, text: string) => ({
Expand Down Expand Up @@ -362,6 +364,48 @@ test("in the desktop app, a file that lost its link to disk is found again, and
await expect(page.locator("#document-name")).toHaveText("list.txt");
});

test("in the desktop app, edits go straight to disk by default, and a single tab can still be closed", async ({ page }) => {
await page.addInitScript(() => {
const disk = { text: "milk\n", modified: 1, writes: 0 };
Object.assign(window, {
disk,
readm3Desktop: {
takeLaunchFiles: async () => [],
onOpenFiles: () => {},
onFilesChanged: () => {},
pick: async () => [{ path: "/home/me/Desktop/list.txt", name: "list.txt", size: disk.text.length }],
reopen: async () => [],
readFile: async () => ({ data: new TextEncoder().encode(disk.text), modified: disk.modified }),
stat: async () => ({ size: disk.text.length, modified: disk.modified }),
readRange: async () => new Uint8Array(),
writeFile: async (_path: string, data: Uint8Array) => { disk.text = new TextDecoder().decode(data); disk.modified++; disk.writes++; },
},
});
});
await page.reload();
await page.locator("#open-files").click();
await expect.poll(() => editorText(page)).toContain("milk");
await expect(page.locator("#disk-direct")).toHaveAttribute("aria-pressed", "true");
// Typed here, written to the file with no Ctrl+S, and nothing left marked unsaved.
await page.locator("#edit-mode").click();
await editorInput(page).fill("milk\neggs\n");
await expect.poll(() => page.evaluate(() => (window as unknown as { disk: { text: string } }).disk.text)).toBe("milk\neggs\n");
await expect(page.locator("#save-status")).toHaveText("Saved to disk · list.txt");
await expect(page.locator("#document-name")).not.toHaveClass(/unsaved/);
// Changed on disk while another file was open: opening it again reads the disk.
await page.locator('#file-list button[title="Welcome.md"]').click();
await page.evaluate(() => { const d = (window as unknown as { disk: { text: string; modified: number } }).disk; d.text = "milk\neggs\nbread\n"; d.modified++; });
await page.locator('#file-list button[title="list.txt"]').click();
await expect.poll(() => editorText(page)).toContain("bread");
// One tab left still has its ×; closing it shows another file.
await page.locator("#tabs .tab", { hasText: "Welcome.md" }).getByRole("button", { name: /Close/ }).click();
await expect(page.locator("#tabs .tab")).toHaveCount(1);
const close = page.locator("#tabs .tab-close");
await expect(close).toBeVisible();
await close.click();
await expect(page.locator("#document-name")).not.toHaveText("list.txt");
});

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
Loading
Loading