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
35 changes: 35 additions & 0 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,10 @@ function menu() {
{ type: "separator" },
item("Save", command("File: Save"), "CmdOrCtrl+S"),
item("Save As…", command("File: Save As…"), "CmdOrCtrl+Shift+S"),
item("Reload from Disk", command("File: Reload from disk"), "CmdOrCtrl+Shift+R"),
item("Close Tab", command("File: Close tab")),
item("Close All but This Tab", command("File: Close all but this tab")),
item("Close All Tabs", command("File: Close all tabs")),
{ type: "separator" },
{ role: "quit" },
] },
Expand Down Expand Up @@ -335,6 +338,38 @@ 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) => {
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 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 }); }
catch { /* Moved or deleted. */ }
}
if (files.length) found[relative] = files;
}
return found;
});
// "Where is this file?": the person points at it in the system dialog, and it is linked again.
ipcMain.handle("locate", async (event, name) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
const { canceled, filePaths } = await dialog.showOpenDialog(window, {
title: `Find ${typeof name === "string" ? basename(name) : "the file"} on disk`,
defaultPath: typeof name === "string" ? join(app.getPath("home"), basename(name)) : undefined,
properties: ["openFile"],
});
if (canceled || !filePaths[0]) return null;
const [file] = await load([filePaths[0]]);
return file ?? null;
});
// 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
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.20.0",
"version": "0.21.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: 2 additions & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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),
locate: (name) => ipcRenderer.invoke("locate", name),
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.28.0",
"version": "0.29.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
5 changes: 5 additions & 0 deletions site/assets/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ html, body.reader-app { height: 100%; overflow: hidden; }
.reader-app .tab-close { padding: 8px 10px 8px 6px; opacity: .5; }
.reader-app .tab-close:hover:not(:disabled) { opacity: 1; color: var(--danger); }
.reader-app .tab-close:disabled { visibility: hidden; }
.tab-menu { position: fixed; z-index: 50; min-width: 200px; padding: 4px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); box-shadow: 0 12px 32px #0006; }
.reader-app .tab-menu button { display: block; width: 100%; padding: 7px 12px; border: 0; border-radius: 4px; background: transparent; color: var(--fg); font: 13px var(--mono); text-align: left; cursor: pointer; }
.reader-app .tab-menu button:hover:not(:disabled), .reader-app .tab-menu button:focus-visible { background: var(--selection); color: var(--selection-text); outline: none; }
.reader-app .tab-menu button:disabled { color: var(--muted); cursor: default; }
.tab-menu hr { margin: 4px 0; border: 0; border-top: 1px solid var(--border); }
.tab.unsaved .tab-name::after { content: " ●"; color: var(--warning); }
.sidebar-hidden .file-pane { display: none; }
/* The palette: Ctrl+P for files, Ctrl+Shift+P for commands. */
Expand Down
2 changes: 2 additions & 0 deletions site/assets/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
<button id="share">Share</button>
<a id="new-paste" class="raw-link" href="/paste" hidden title="Make another paste">New paste</a>
<button id="paste-delete" hidden>Delete link</button>
<button id="reload-disk" title="Read this file again from disk (Ctrl or ⌘ Shift R)">Reload</button>
<button id="history" hidden>History</button>
<button id="summarize" title="An AI summary of this document (10 free a week, then pay as you go)">Summarize</button>
<button id="ask-ai" title="Ask AI about this file, its folder or your workspace (10 free a day, then pay as you go)">Ask AI</button>
Expand All @@ -66,6 +67,7 @@
</div>
</div>
<nav id="tabs" class="tab-bar" role="tablist" aria-label="Open files" hidden></nav>
<div id="tab-menu" class="tab-menu" role="menu" hidden></div>
<div class="reader-options">
<span id="document-info">Markdown</span>
<div class="reader-selects">
Expand Down
7 changes: 7 additions & 0 deletions site/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ 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. */
locate?(name: string): Promise<DesktopFile | null>;
}
/** 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 @@ -146,6 +150,9 @@ async function whole(entry: DesktopFile): Promise<File> {
export const desktopPath = (handle: FileSystemFileHandle): string | undefined =>
(handle as FileSystemFileHandle & { desktopFile?: DesktopFile }).desktopFile?.path;

/** A desktop file found again (find-opened, locate) as a handle the editor reads and saves through. */
export const linkDesktop = (file: DesktopFile): FileSystemFileHandle => desktopHandle(file);

/** A handle over a path the desktop app may read and write; a Save As path has no size or bytes yet. */
function desktopHandle(entry: DesktopFile): FileSystemFileHandle {
return {
Expand Down
61 changes: 60 additions & 1 deletion site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,9 @@ test("in the desktop app, a save in another editor shows at once, even over a co
}));
await page.reload();
await page.locator('#file-list button[title="list.txt"]').click();
await expect.poll(() => editorText(page)).toContain("from the phone");
// The disk is the file: on launch the copy sync brought gives way to it (and stays in History).
await expect.poll(() => editorText(page)).toContain("bread");
await expect.poll(() => editorText(page)).not.toContain("from the phone");
// The next Sublime save wins over that copy, which stays in History.
await page.evaluate(() => (window as unknown as { sublimeSave(text: string): void }).sublimeSave("milk\nbread\neggs\n"));
await expect.poll(() => editorText(page), { timeout: 2000 }).toContain("eggs");
Expand All @@ -303,6 +305,63 @@ test("in the desktop app, a save in another editor shows at once, even over a co
await expect.poll(() => editorText(page)).toContain("butter");
});

test("in the desktop app, a file that lost its link to disk is found again, and Reload reads it from disk", async ({ page }) => {
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 path = "/home/me/Desktop/list.txt";
const entry = () => ({ path, name: "list.txt", size: file().text.length });
Object.assign(window, {
readm3Desktop: {
takeLaunchFiles: async () => [],
onOpenFiles: () => {},
onFilesChanged: () => {},
pick: async () => [entry()],
// An old build lost the link: nothing comes back from reopen.
reopen: async () => [],
findOpened: async (relatives: string[]) => localStorage.getItem("test:known") ? Object.fromEntries(relatives.filter((r) => r === "list.txt").map((r) => [r, [entry()]])) : {},
locate: async () => { localStorage.setItem("test:located", "1"); return entry(); },
readFile: async () => ({ 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.poll(() => editorText(page)).toContain("milk");
// Saved in Sublime while the link was lost: the copy here is stale.
await page.evaluate(() => localStorage.setItem("test:disk", JSON.stringify({ text: "milk\nbread\n", modified: 2 })));
await page.reload();
await page.locator('#file-list button[title="list.txt"]').click();
await expect.poll(() => editorText(page)).not.toContain("bread");
// Reload asks where the file is, and reads it from there.
await page.locator("#reload-disk").click();
await expect.poll(() => editorText(page)).toContain("bread");
expect(await page.evaluate(() => localStorage.getItem("test:located"))).toBe("1");
// Next launch the desktop app knows the path, and the link comes back on its own.
await page.evaluate(() => { localStorage.setItem("test:known", "1"); localStorage.setItem("test:disk", JSON.stringify({ text: "milk\nbread\neggs\n", modified: 3 })); });
await page.reload();
await page.locator('#file-list button[title="list.txt"]').click();
await expect.poll(() => editorText(page)).toContain("eggs");

// Right-click a tab: reload, close it, all but it, or all.
await page.locator('#file-list button[title="Welcome.md"]').click();
await page.locator('#file-list button[title="list.txt"]').click();
await expect(page.locator("#tabs .tab")).toHaveCount(2);
await page.locator("#tabs .tab", { hasText: "list.txt" }).click({ button: "right" });
const menu = page.locator("#tab-menu");
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem")).toHaveText(["Reload from disk", "Close tab", "Close all but this tab", "Close all tabs"]);
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await page.locator("#tabs .tab", { hasText: "list.txt" }).click({ button: "right" });
await menu.getByRole("menuitem", { name: "Close all but this tab" }).click();
await expect(page.locator("#tabs .tab")).toHaveCount(1);
await expect(page.locator("#document-name")).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