From e7cdda9bc8c9ba7ee8c10a82f8713fe24169f132 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 13:43:10 +0000 Subject: [PATCH] Desktop files read from disk open and sync after a restart; no more "open the folder again" (v0.26.2) Anthony: "syncing still broken: That stored file chunk is not here. i get this a lot now. and it says to re-open the folder, why do we have to do that?" Large files, PDFs and images the desktop app reads from disk had a reader for one session only. #66 restored the disk handles after a restart but not these readers, so after relaunching the desktop could not open them ("Open its folder again"), sync could not upload them, and the account held references with no bytes: the web editor's "That stored file chunk is not here". - Every stored file with a restored disk handle gets its reader back on launch and after each sync, so it opens and the next sync uploads what the account lacks. - A file changed on disk to another size is skipped for that upload instead of stopping the whole pass on the server's chunk-size check. - A stored file the account does not hold yet says so in words; "open the folder" is left only for a file that moved or was deleted on disk. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- site/autosync.ts | 4 +++- site/blobs.ts | 2 ++ site/test/viewer.spec.ts | 30 ++++++++++++++++++++++++++++++ site/viewer.ts | 18 ++++++++++++++++-- src/cli.ts | 2 +- 6 files changed, 53 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ab258b9..9d00324 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.26.1", + "version": "0.26.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/autosync.ts b/site/autosync.ts index 8c7d776..78593d9 100644 --- a/site/autosync.ts +++ b/site/autosync.ts @@ -199,7 +199,9 @@ async function forUpload(workspace: SyncedWorkspace, remote: SyncedWorkspace | u if (!complete.has(doc.blob.id)) { const kept = await blobs.local(doc.blob); // A file that is on neither side cannot be sent; its reference travels so it is not lost elsewhere. - if (kept) { host?.status(`Uploading ${doc.path.split("/").at(-1)} (${formatStorage(doc.blob.size)})…`); await blobs.upload(doc.blob, kept); complete.add(doc.blob.id); } + // Nor can one changed on disk since it was opened (another size): the server would refuse + // every chunk and stop the whole pass; it goes once the editor picks the change up. + if (kept && kept.size === doc.blob.size) { host?.status(`Uploading ${doc.path.split("/").at(-1)} (${formatStorage(doc.blob.size)})…`); await blobs.upload(doc.blob, kept); complete.add(doc.blob.id); } } documents.push(doc); continue; diff --git a/site/blobs.ts b/site/blobs.ts index 240384e..fa26d37 100644 --- a/site/blobs.ts +++ b/site/blobs.ts @@ -40,6 +40,8 @@ export const account = () => request("blobs"); export const remove = (id: string) => request(`blobs/${id}`, "DELETE"); async function failure(response: Response): Promise { + // Named on the account by another device that has not uploaded it (yet): say so, not "chunk". + if (response.status === 404) return new ApiError("This file is not on your account yet. The device that has it uploads it on its next sync; open readm3 there.", 404); let message = `Storage request failed (${response.status})`; try { message = (await response.json()).error || message; } catch { /* Not JSON. */ } return new ApiError(message, response.status); diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 2c4f247..65c417f 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -236,6 +236,36 @@ 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 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"); + const path = "/home/me/Desktop/report.pdf"; + const opened = new Set(JSON.parse(localStorage.getItem("test:opened") ?? "[]")); + Object.assign(window, { + readm3Desktop: { + takeLaunchFiles: async () => [], + onOpenFiles: () => {}, + pick: async () => { opened.add(path); localStorage.setItem("test:opened", JSON.stringify([...opened])); return [{ path, name: "report.pdf", size: pdf.length }]; }, + reopen: async (paths: string[]) => paths.filter((p) => opened.has(p)).map((p) => ({ path: p, name: "report.pdf", size: pdf.length })), + readFile: async (p: string) => { if (!opened.has(p)) throw new Error("readm3 can only read files it opened."); return { data: pdf, modified: 1 }; }, + stat: async () => ({ size: pdf.length, modified: 1 }), + readRange: async () => new Uint8Array(), + writeFile: async () => {}, + }, + }); + }); + await page.reload(); + await page.locator("#open-files").click(); + await expect(page.locator("#document-name")).toHaveText("report.pdf"); + await expect(page.locator("#media-content embed")).toBeVisible(); + await page.locator('#file-list button[title="Welcome.md"]').click(); + // Quit and launch again: the PDF is still read from disk. + await page.reload(); + await page.locator('#file-list button[title="report.pdf"]').click(); + await expect(page.locator("#media-content embed")).toBeVisible(); + await expect(page.locator("#code-content")).not.toContainText("Open the folder"); +}); + 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 f3f93b0..9e7169f 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -55,6 +55,18 @@ let editorLoading: Promise | undefined; let editorPath = ""; /** Files opened from disk that Ctrl+S writes back to, by workspace path. */ let diskEntries = new Map(); +/** + * A large file, PDF or image the desktop app opened is read from disk, never copied into + * browser storage, and its reader lasts one session. After a restart (handles restored by + * disk.loadEntries) or a sync, each one gets its reader back, so it opens, and sync can + * upload it, without opening the folder again. + */ +function reattachDisk() { + for (const doc of workspace.documents) { + const entry = diskEntries.get(doc.path); + if (doc.blob && entry) stored.fromDisk(doc.blob, () => entry.handle.getFile()); + } +} /** Whether the file has a place on disk and differs from what was last read or written there. */ let diskSynced = new Map(); /** A copy that holds nothing the disk lacks: the same text, or unedited since it was last read or saved. */ @@ -232,6 +244,7 @@ async function applySynced(incoming: Workspace | undefined, settings: ReaderSett if (incoming) { await persist(incoming); workspace = incoming; + reattachDisk(); filter.value = ""; foldLarge(workspace.active); } @@ -525,9 +538,9 @@ function renderStored() { }) .catch((error) => { if (!still()) return; - // A file the desktop app reads from disk is there only while the app has it open. + // Read from disk and gone from there (moved, renamed or deleted), with no copy on the account yet. status.textContent = diskSynced.has(doc.path) || diskEntries.has(doc.path) - ? "This file is read from disk. Open its folder again (File: Open folder…) to read it." + ? "This file was read from disk and is not there any more (moved, renamed or deleted). Open the folder that holds it now to read it again." : `${error instanceof Error ? error.message : "This file could not be opened."}${signedIn ? "" : " Sign in to fetch it from your account."}`; previousRender = ""; }); @@ -1342,6 +1355,7 @@ async function start() { if (!seed.ok) throw new Error("Could not load the starter documents. Reload to try again."); workspace = await seed.json() as Workspace; } + reattachDisk(); if (savedActive && workspace.documents.some((doc) => doc.path === savedActive)) workspace.active = savedActive; workspace.documents.sort((a, b) => comparePaths(a.path, b.path)); openDocument(workspace.active, false); diff --git a/src/cli.ts b/src/cli.ts index cda2eed..55d5c93 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.26.1"; +export const VERSION = "0.26.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