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