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
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.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",
Expand Down
4 changes: 3 additions & 1 deletion site/autosync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions site/blobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const account = () => request<StorageAccount>("blobs");
export const remove = (id: string) => request(`blobs/${id}`, "DELETE");

async function failure(response: Response): Promise<ApiError> {
// 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);
Expand Down
30 changes: 30 additions & 0 deletions site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<</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");
const path = "/home/me/Desktop/report.pdf";
const opened = new Set<string>(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(() => {
Expand Down
18 changes: 16 additions & 2 deletions site/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ let editorLoading: Promise<SourceEditor> | undefined;
let editorPath = "";
/** Files opened from disk that Ctrl+S writes back to, by workspace path. */
let diskEntries = new Map<string, disk.DiskEntry>();
/**
* 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<string, number>();
/** A copy that holds nothing the disk lacks: the same text, or unedited since it was last read or saved. */
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 = "";
});
Expand Down Expand Up @@ -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);
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.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
Expand Down
Loading