diff --git a/desktop/main.cjs b/desktop/main.cjs index 8914e33..cb0a56e 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -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" }, ] }, @@ -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."); diff --git a/desktop/package.json b/desktop/package.json index b0ac1eb..5a529b9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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. ", diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 78a0cfa..0cb8c8e 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -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), diff --git a/package.json b/package.json index 3f5a026..9718746 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/site/assets/viewer.css b/site/assets/viewer.css index 925ca0e..264c38c 100644 --- a/site/assets/viewer.css +++ b/site/assets/viewer.css @@ -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. */ diff --git a/site/assets/viewer.html b/site/assets/viewer.html index c86ef9f..78833cd 100644 --- a/site/assets/viewer.html +++ b/site/assets/viewer.html @@ -57,6 +57,7 @@ + @@ -66,6 +67,7 @@ +
Markdown
diff --git a/site/disk.ts b/site/disk.ts index 5b0b075..62249bb 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -103,6 +103,10 @@ interface DesktopBridge { reopen?(paths: string[]): Promise; /** 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>; + /** Absent before 0.21.0: the person picks where a workspace file is on disk. */ + locate?(name: string): Promise; } /** Present only inside the readm3 desktop app (desktop/preload.cjs). */ export const desktop = typeof window === "undefined" ? undefined : (window as unknown as { readm3Desktop?: DesktopBridge }).readm3Desktop; @@ -146,6 +150,9 @@ async function whole(entry: DesktopFile): Promise { 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 { diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index c97eaa9..c19ac2d 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -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"); @@ -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<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\ntrailer<>\n%%EOF\n"); diff --git a/site/viewer.ts b/site/viewer.ts index 262f88e..1405f83 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -118,6 +118,7 @@ function applyKind() { : current.binary.kind === "document" ? workspace.active.split(".").at(-1)!.toUpperCase() : current.binary.label.split(" ")[0]!.toUpperCase(); element("edit-mode").disabled = current.kind === "binary" || isLarge() || !!pasteDocument || (!!cloudDocument && !cloudDocument.canEdit); if (!cloudDocument && !pasteDocument) element("history").hidden = current.kind === "binary" || isLarge(); + element("reload-disk").hidden = !!cloudDocument || !!pasteDocument; } /** Whether the active file may be edited here: not a paste, not a view-only link, not a PDF, image or file over 4 MB. */ @@ -309,6 +310,7 @@ function renderTabs() { open.textContent = path.split("/").at(-1)!; open.onclick = () => { if (path !== workspace.active) openDocument(path); }; open.onauxclick = (event) => { if (event.button === 1) closeTab(path); }; + tab.oncontextmenu = (event) => { event.preventDefault(); showTabMenu(path, event.clientX, event.clientY); }; const close = document.createElement("button"); close.className = "tab-close"; close.textContent = "×"; @@ -332,6 +334,48 @@ function closeTab(path: string) { else renderTabs(); } +function closeOtherTabs(path: string) { + tabs = [path]; + if (path === workspace.active) renderTabs(); else openDocument(path); +} + +/** Every tab closes; one file is always on screen, so the first in the file tree takes its place. */ +function closeAllTabs() { + const first = workspace.documents[0]!.path; + tabs = [first]; + if (first === workspace.active) renderTabs(); else openDocument(first); +} + +/** Right-click on a tab: reload it from disk, or close it, the others, or all of them. */ +const tabMenu = element("tab-menu"); +function hideTabMenu() { tabMenu.hidden = true; tabMenu.replaceChildren(); } +function showTabMenu(path: string, x: number, y: number) { + const item = (label: string, run: () => void, disabled = false) => { + const button = document.createElement("button"); + button.setAttribute("role", "menuitem"); + button.textContent = label; + button.disabled = disabled; + button.onclick = () => { hideTabMenu(); run(); }; + return button; + }; + tabMenu.replaceChildren( + item("Reload from disk", () => void reloadFromDisk(path)), + document.createElement("hr"), + item("Close tab", () => closeTab(path), tabs.length < 2), + item("Close all but this tab", () => closeOtherTabs(path), tabs.length < 2), + item("Close all tabs", closeAllTabs, tabs.length < 2 && tabs[0] === workspace.documents[0]?.path), + ); + tabMenu.hidden = false; + const { width, height } = tabMenu.getBoundingClientRect(); + tabMenu.style.left = `${Math.max(4, Math.min(x, innerWidth - width - 4))}px`; + tabMenu.style.top = `${Math.max(4, Math.min(y, innerHeight - height - 4))}px`; + tabMenu.querySelector("button:not(:disabled)")?.focus(); +} +document.addEventListener("pointerdown", (event) => { if (!tabMenu.hidden && !tabMenu.contains(event.target as Node)) hideTabMenu(); }); +document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !tabMenu.hidden) { event.stopPropagation(); hideTabMenu(); } }, true); +window.addEventListener("blur", hideTabMenu); +window.addEventListener("resize", hideTabMenu); + function showPalette(initial: string) { const files = (): PaletteItem[] => workspace.documents.map((doc) => ({ label: doc.path, detail: kindOf(doc).kind === "binary" ? "view" : undefined, run: () => openDocument(doc.path) })); openPalette({ @@ -354,7 +398,10 @@ function commands(): PaletteItem[] { command("File: New note", () => element("new-file").click()), command("File: Save", () => void saveFile(), "Ctrl+S"), command("File: Save As…", () => void saveFile(true), "Ctrl+Shift+S"), + command("File: Reload from disk", () => void reloadFromDisk(), disk.desktop ? "Ctrl+Shift+R" : undefined), command("File: Close tab", () => closeTab(workspace.active)), + command("File: Close all but this tab", () => closeOtherTabs(workspace.active)), + command("File: Close all tabs", closeAllTabs), command("File: History", openHistory), command("Billing: Add funds", () => void showTopUp()), command("Billing: Usage and limits", showUsage), @@ -896,12 +943,61 @@ async function checkDisk(all = false) { }); } +/** + * A workspace file with no link to disk (opened before desktop 0.19.0, or brought by sync) + * is linked again to the file the desktop app opened at that path, so it is read from disk. + * With `ask`, and nothing or more than one found, the person points at it in a dialog. + */ +async function relink(paths: string[], ask = false): Promise { + const bridge = disk.desktop; + if (!bridge?.findOpened) return []; + const linked: string[] = []; + let found: Record = {}; + try { found = await bridge.findOpened(paths); } catch { /* An older desktop build. */ } + for (const path of paths) { + let file = found[path]?.length === 1 ? found[path]![0] : undefined; + if (!file && ask && bridge.locate) file = await bridge.locate(path.split("/").at(-1)!) ?? undefined; + if (!file) continue; + // No fingerprint is -1, so the next check reads the file. + diskEntries.set(path, { handle: disk.linkDesktop(file), saved: -1 }); + linked.push(path); + } + if (linked.length) { void disk.storeEntries(diskEntries); renderFiles(); } + return linked; +} + +/** Reads a file again from disk now, whatever this copy holds; unsaved typing is asked about first. */ +async function reloadFromDisk(path = workspace.active) { + if (cloudDocument || pasteDocument) return; + const doc = workspace.documents.find((candidate) => candidate.path === path); + if (!doc) return; + const name = path.split("/").at(-1)!; + if (!diskEntries.has(path)) { + if (!disk.desktop?.findOpened) { + notice(disk.desktop + ? `${name} is not linked to a file on disk. Install the latest readm3 desktop app, or open the file again (File → Open Files).` + : `${name} is a copy kept in this browser, not linked to a file on disk. Open the file again (Open files) to read it from disk.`); + return; + } + if (!(await relink([path], true)).length) { notice(`${name} is not linked to a file on disk.`); return; } + } + if (typedHere.has(path) && unsavedOnDisk(doc)) { + if (!confirm(`Replace your unsaved edits to ${name} with the version on disk? Your edits stay in History.`)) return; + await fileHistory.record(path, doc.source, "replaced by disk"); + typed(path, false); + } + const before = doc.source; + diskSeen.delete(path); + await queueDisk(() => checkDiskFile(path, true, true)); + if (doc.source === before && workspace.active === path) notice(`${name} already matches the file on disk.`); +} + /** * A file from disk that changes underneath the editor (regenerated output, a git * checkout, another editor) gets a version in History. With no local edits it is * reloaded; with local edits it is left alone and the disk version waits in History. */ -async function checkDiskFile(path: string, reported = false) { +async function checkDiskFile(path: string, reported = false, force = false) { const doc = workspace.documents.find((candidate) => candidate.path === path); const entry = diskEntries.get(path); if (!doc || !entry) return; @@ -923,7 +1019,7 @@ async function checkDiskFile(path: string, reported = false) { } let file: File; try { file = await entry.handle.getFile(); } catch { return; } - if (!reported && diskSeen.get(path) === file.lastModified) return; + if (!reported && !force && diskSeen.get(path) === file.lastModified) return; diskSeen.set(path, file.lastModified); if (binaryType(path)) { const source = toBase64(new Uint8Array(await file.arrayBuffer())); @@ -936,12 +1032,26 @@ async function checkDiskFile(path: string, reported = false) { return; } const text = await file.text(); - if (disk.fingerprint(text) === entry.saved) return; - // Only typing here holds the disk back; a copy sync brought is replaced, and kept in History. - const edited = unsavedOnDisk(doc) && typedHere.has(path); - await fileHistory.record(path, text, "changed on disk"); - if (edited) { if (active()) notice(`${name} changed on disk. Your unsaved edits are kept; the disk version is in History.`); return; } - if (doc.source !== text && unsavedOnDisk(doc)) await fileHistory.record(path, doc.source, "replaced by disk"); + // The disk is the file. A copy here that differs from it without anyone typing (another + // device's version from sync, a copy from before its link to disk was lost) is replaced; + // only typing here holds a change on disk back, and both versions stay in History. + const fingerprint = disk.fingerprint(text); + const diskMoved = fingerprint !== entry.saved; + if (doc.source === text) { + if (diskMoved) { entry.saved = fingerprint; typed(path, false); void disk.storeEntries(diskEntries); if (active()) markUnsaved(); } + return; + } + const edited = !force && unsavedOnDisk(doc) && typedHere.has(path); + if (edited) { + if (!diskMoved) return; + entry.saved = fingerprint; + void disk.storeEntries(diskEntries); + await fileHistory.record(path, text, "changed on disk"); + if (active()) { markUnsaved(); notice(`${name} changed on disk. Your unsaved edits are kept; the disk version is in History. Reload from disk to take it.`); } + return; + } + if (unsavedOnDisk(doc)) await fileHistory.record(path, doc.source, "replaced by disk"); + if (diskMoved) await fileHistory.record(path, text, "changed on disk"); entry.saved = disk.fingerprint(text); typed(path, false); void disk.storeEntries(diskEntries); @@ -1096,6 +1206,7 @@ function bindEvents() { if (pendingSave) { notice("Save or download your draft before restoring a version."); return; } void versionHistory(cloudDocument, adoptCloud).catch(error => notice(error instanceof Error ? error.message : "Could not load history.")); }; + element("reload-disk").onclick = () => void reloadFromDisk(); element("open-files").onclick = () => void openFromDisk(false); element("open-folder").onclick = () => void openFromDisk(true); filesInput.onchange = () => void importFiles(copies(filesInput.files ?? [])); @@ -1142,6 +1253,8 @@ function onKey(event: KeyboardEvent) { event.preventDefault(); showPalette(event.shiftKey || event.key === "F1" ? ">" : ""); return; } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void saveFile(event.shiftKey); return; } + // Desktop only: in a browser Ctrl+Shift+R is the hard reload people expect. + if (disk.desktop && (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "r") { event.preventDefault(); void reloadFromDisk(); return; } if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.key === "Escape") { if (editing) setMode(false); @@ -1439,6 +1552,8 @@ async function start() { if (remote) await openUrlParam(remote); receiveLaunches(); watchDesktopFiles(); + // Files whose link to disk was lost are linked again where the desktop app knows the path. + await relink(workspace.documents.filter((doc) => !diskEntries.has(doc.path)).map((doc) => doc.path)); // Files from disk changed while readm3 was closed are picked up now, not one round at a time. void checkDisk(true); setInterval(() => void checkDisk(), 5000); diff --git a/src/cli.ts b/src/cli.ts index 9d6f80a..809e6d9 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.28.0"; +export const VERSION = "0.29.0"; /** 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