diff --git a/desktop/main.cjs b/desktop/main.cjs index cb0a56e..94b8e18 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -339,23 +339,56 @@ 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) => { + // found among the paths opened before, else in the home folder, so the page reads it from + // disk again without asking. Several matches: the most recently changed one. + ipcMain.handle("find-opened", async (event, relatives, search = false) => { 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 wanted = relatives.filter((relative) => typeof relative === "string" && relative && !relative.split("/").includes("..") && !NEVER.test(relative)); 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 }); } + const pick = async (relative, paths) => { + let best; + for (const path of new Set(paths)) { + try { const info = await stat(path); if (info.isFile() && info.size <= MAX_FILE_BYTES && (!best || info.mtimeMs > best.modified)) best = { path, name: basename(path), size: info.size, modified: info.mtimeMs }; } catch { /* Moved or deleted. */ } } - if (files.length) found[relative] = files; - } + if (!best) return false; + remember(best.path); + found[relative] = [{ path: best.path, name: best.name, size: best.size }]; + return true; + }; + const matches = (path, relative) => path === relative || path.endsWith(`/${relative}`) || path.endsWith(`\\${relative.replaceAll("/", "\\")}`); + 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]); } + let missing = []; + for (const relative of wanted) if (!await pick(relative, (byName.get(basename(relative)) ?? []).filter((path) => matches(path, relative)))) missing.push(relative); + // Searching is asked for only by Reload on one file: a note made in readm3 must never be + // tied to some unrelated file of the same name. + if (!missing.length || search !== true) return found; + // The usual places first: ~/Desktop/notes.md opened as "notes.md" or "Desktop/notes.md". + const home = app.getPath("home"); + const roots = [home, ...["desktop", "documents", "downloads"].map((name) => { try { return app.getPath(name); } catch { return undefined; } }).filter(Boolean), join(home, "src"), join(home, "notes")]; + const still = []; + for (const relative of missing) if (!await pick(relative, roots.map((root) => join(root, relative)))) still.push(relative); + missing = still; + if (!missing.length) return found; + // Then a bounded walk of the home folder, for a file opened from deeper in it. + const names = new Map(missing.map((relative) => [basename(relative), []])); + const deadline = Date.now() + 4000; + let folders = 0; + const walk = async (directory, depth) => { + if (depth > 6 || folders++ > 50_000 || Date.now() > deadline) return; + let entries; + try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) await walk(path, depth + 1); + else if (entry.isFile()) names.get(entry.name)?.push(path); + } + }; + await walk(home, 0); + for (const relative of missing) await pick(relative, (names.get(basename(relative)) ?? []).filter((path) => matches(path, relative))); return found; }); // "Where is this file?": the person points at it in the system dialog, and it is linked again. diff --git a/desktop/package.json b/desktop/package.json index 5a529b9..43e5b69 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.21.0", + "version": "0.30.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 0cb8c8e..af4868f 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -17,7 +17,7 @@ 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), + findOpened: (relatives, search) => ipcRenderer.invoke("find-opened", relatives, search), locate: (name) => ipcRenderer.invoke("locate", name), writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data), pick: (folder) => ipcRenderer.invoke("pick", folder), diff --git a/package.json b/package.json index 9718746..cce2fbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.29.0", + "version": "0.30.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.html b/site/assets/viewer.html index 78833cd..622bb5d 100644 --- a/site/assets/viewer.html +++ b/site/assets/viewer.html @@ -75,6 +75,7 @@ +
diff --git a/site/disk.ts b/site/disk.ts index 62249bb..c1f58ba 100644 --- a/site/disk.ts +++ b/site/disk.ts @@ -103,9 +103,9 @@ 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. */ + /** Absent before 0.29.0: opened disk files whose path ends in each workspace path; `search` also looks through the home folder. */ + findOpened?(relatives: string[], search?: boolean): Promise>; + /** Absent before 0.29.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). */ diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index c19ac2d..2d17891 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -60,6 +60,8 @@ test("imports safely, edits, downloads exact source, and restores the draft afte test("opens a folder from disk, marks unsaved files, and writes them back with Ctrl+S", async ({ page }) => { // Fake File System Access handles: the real pickers cannot be driven headless. await page.addInitScript(() => { + // Auto-save to disk off: edits wait for Ctrl+S (the optional keep-a-copy way). + localStorage.setItem("readm3:disk-direct", "0"); const written: Record = {}; (window as unknown as { written: typeof written }).written = written; const file = (name: string, text: string) => ({ @@ -362,6 +364,48 @@ test("in the desktop app, a file that lost its link to disk is found again, and await expect(page.locator("#document-name")).toHaveText("list.txt"); }); +test("in the desktop app, edits go straight to disk by default, and a single tab can still be closed", async ({ page }) => { + await page.addInitScript(() => { + const disk = { text: "milk\n", modified: 1, writes: 0 }; + Object.assign(window, { + disk, + readm3Desktop: { + takeLaunchFiles: async () => [], + onOpenFiles: () => {}, + onFilesChanged: () => {}, + pick: async () => [{ path: "/home/me/Desktop/list.txt", name: "list.txt", size: disk.text.length }], + reopen: async () => [], + readFile: async () => ({ data: new TextEncoder().encode(disk.text), modified: disk.modified }), + stat: async () => ({ size: disk.text.length, modified: disk.modified }), + readRange: async () => new Uint8Array(), + writeFile: async (_path: string, data: Uint8Array) => { disk.text = new TextDecoder().decode(data); disk.modified++; disk.writes++; }, + }, + }); + }); + await page.reload(); + await page.locator("#open-files").click(); + await expect.poll(() => editorText(page)).toContain("milk"); + await expect(page.locator("#disk-direct")).toHaveAttribute("aria-pressed", "true"); + // Typed here, written to the file with no Ctrl+S, and nothing left marked unsaved. + await page.locator("#edit-mode").click(); + await editorInput(page).fill("milk\neggs\n"); + await expect.poll(() => page.evaluate(() => (window as unknown as { disk: { text: string } }).disk.text)).toBe("milk\neggs\n"); + await expect(page.locator("#save-status")).toHaveText("Saved to disk · list.txt"); + await expect(page.locator("#document-name")).not.toHaveClass(/unsaved/); + // Changed on disk while another file was open: opening it again reads the disk. + await page.locator('#file-list button[title="Welcome.md"]').click(); + await page.evaluate(() => { const d = (window as unknown as { disk: { text: string; modified: number } }).disk; d.text = "milk\neggs\nbread\n"; d.modified++; }); + await page.locator('#file-list button[title="list.txt"]').click(); + await expect.poll(() => editorText(page)).toContain("bread"); + // One tab left still has its ×; closing it shows another file. + await page.locator("#tabs .tab", { hasText: "Welcome.md" }).getByRole("button", { name: /Close/ }).click(); + await expect(page.locator("#tabs .tab")).toHaveCount(1); + const close = page.locator("#tabs .tab-close"); + await expect(close).toBeVisible(); + await close.click(); + await expect(page.locator("#document-name")).not.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 1405f83..4436c9f 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -81,6 +81,55 @@ const unsavedOnDisk = (doc: Document) => { const entry = diskEntries.get(doc.pat * (both versions stay in History). Kept across restarts so unsaved typing survives one. */ const typedHere = new Set(((): string[] => { try { return JSON.parse(localStorage.getItem("readm3:typed") ?? "[]"); } catch { return []; } })()); +/** + * Straight to disk (the default): an edit to a file from disk is written to it as you type, + * and a file is read from disk again whenever it is opened. Off, readm3 keeps your edits on + * this device until Ctrl+S, as before. + */ +const diskDirect = (): boolean => { try { return localStorage.getItem("readm3:disk-direct") !== "0"; } catch { return true; } }; +let diskWriteTimer: ReturnType | undefined; +let diskWritePath = ""; +function writeThrough(path: string) { + if (!diskDirect() || !diskEntries.has(path)) return; + if (diskWritePath && diskWritePath !== path) void flushToDisk(); + diskWritePath = path; + clearTimeout(diskWriteTimer); + diskWriteTimer = setTimeout(() => void flushToDisk(), 400); +} +async function flushToDisk() { + clearTimeout(diskWriteTimer); + const path = diskWritePath; + diskWritePath = ""; + const doc = workspace.documents.find((candidate) => candidate.path === path); + const entry = diskEntries.get(path); + if (!doc || !entry || doc.blob || binaryType(path) || !unsavedOnDisk(doc)) return; + const source = doc.source; + try { + await disk.write(entry.handle, source); + entry.saved = disk.fingerprint(source); + if (doc.source === source) typed(path, false); + void disk.storeEntries(diskEntries); + if (workspace.active === path) { markUnsaved(); saveStatus.textContent = `Saved to disk · ${path.split("/").at(-1)}`; } + } catch (error) { + notice(`${error instanceof Error ? error.message : "Could not write the file."} Your edits are kept here; Ctrl+S tries again.`); + } +} +window.addEventListener("beforeunload", () => { if (diskWritePath) void flushToDisk(); }); +async function syncedToDisk(path: string) { + const doc = workspace.documents.find((candidate) => candidate.path === path); + const entry = diskEntries.get(path); + if (!doc || !entry) return; + let text: string; + try { text = await (await entry.handle.getFile()).text(); } catch { return; } + if (disk.fingerprint(text) !== entry.saved || text === doc.source) return; + try { + await disk.write(entry.handle, doc.source); + entry.saved = disk.fingerprint(doc.source); + void disk.storeEntries(diskEntries); + void fileHistory.record(path, text, "replaced by disk"); + if (workspace.active === path) markUnsaved(); + } catch { /* The copy stays here, marked unsaved; Ctrl+S writes it. */ } +} function typed(path: string, on = true) { if (on === typedHere.has(path)) return; if (on) typedHere.add(path); else typedHere.delete(path); @@ -119,6 +168,7 @@ function applyKind() { 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; + element("disk-direct").hidden = !!cloudDocument || !!pasteDocument || !disk.supported(); } /** 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. */ @@ -257,9 +307,17 @@ async function applySynced(incoming: Workspace | undefined, settings: ReaderSett try { clearTimeout(saveTimer); if (incoming) { + // Straight to disk: a file another device changed is written to its file here too, + // unless the file changed on disk meanwhile (then the disk wins, as everywhere else). + const before = new Map(workspace.documents.map((doc) => [doc.path, doc])); + const toDisk = diskDirect() ? incoming.documents.filter((doc) => { + const old = before.get(doc.path); + return !!old && diskEntries.has(doc.path) && !doc.blob && !binaryType(doc.path) && doc.source !== old.source && !unsavedOnDisk(old); + }).map((doc) => doc.path) : []; await persist(incoming); workspace = incoming; reattachDisk(); + for (const path of toDisk) void queueDisk(() => syncedToDisk(path)); filter.value = ""; foldLarge(workspace.active); } @@ -315,7 +373,7 @@ function renderTabs() { close.className = "tab-close"; close.textContent = "×"; close.setAttribute("aria-label", `Close ${path}`); - close.disabled = tabs.length === 1; + close.disabled = workspace.documents.length < 2; close.onclick = () => closeTab(path); tab.append(open, close); return tab; @@ -327,7 +385,14 @@ function renderTabs() { } function closeTab(path: string) { - if (tabs.length < 2) return; + // The last tab gives way to another file: one file is always on screen. + if (tabs.length < 2) { + const next = workspace.documents.find((doc) => doc.path !== path)?.path; + if (!next) return; + tabs = [next]; + openDocument(next); + return; + } const index = tabs.indexOf(path); tabs = tabs.filter((tab) => tab !== path); if (path === workspace.active) openDocument(tabs[Math.min(index, tabs.length - 1)]!); @@ -361,7 +426,7 @@ function showTabMenu(path: string, x: number, y: number) { tabMenu.replaceChildren( item("Reload from disk", () => void reloadFromDisk(path)), document.createElement("hr"), - item("Close tab", () => closeTab(path), tabs.length < 2), + item("Close tab", () => closeTab(path), workspace.documents.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), ); @@ -399,6 +464,7 @@ function commands(): PaletteItem[] { 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: ${diskDirect() ? "Turn off" : "Turn on"} auto-save to disk`, () => element("disk-direct").click()), command("File: Close tab", () => closeTab(workspace.active)), command("File: Close all but this tab", () => closeOtherTabs(workspace.active)), command("File: Close all tabs", closeAllTabs), @@ -661,7 +727,7 @@ function renderDocument() { function loadEditor(): Promise { return editorLoading ??= import("./code-editor.ts").then(({ createEditor }) => { - sourceEditor = createEditor(editor, (text) => { current().source = text; if (diskEntries.has(workspace.active)) typed(workspace.active); documentInfo(); changed(); }); + sourceEditor = createEditor(editor, (text) => { current().source = text; if (diskEntries.has(workspace.active)) { typed(workspace.active); writeThrough(workspace.active); } documentInfo(); changed(); }); sourceEditor.view.scrollDOM.addEventListener("scroll", progress); return sourceEditor; }); @@ -738,6 +804,8 @@ function openDocument(path: string, focus = true) { progress(); setFilesOpen(false); if (!cloudDocument) changed(); + // Straight to disk: what is on screen is what is on disk, read again on every open. + if (diskDirect() && !cloudDocument && !pasteDocument && diskEntries.has(path)) void queueDisk(() => checkDiskFile(path, true)); } /** Adds files, renaming any that would replace an open draft; returns them in the order given. */ @@ -953,7 +1021,8 @@ async function relink(paths: string[], ask = false): Promise { if (!bridge?.findOpened) return []; const linked: string[] = []; let found: Record = {}; - try { found = await bridge.findOpened(paths); } catch { /* An older desktop build. */ } + // Asked for (Reload), the desktop app also searches the home folder for the file. + try { found = await bridge.findOpened(paths, ask); } 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; @@ -975,11 +1044,12 @@ async function reloadFromDisk(path = workspace.active) { 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.`); + ? `${name} is not linked to a file on disk, and this desktop build cannot find it. Install the latest readm3 desktop app.` + : `${name} is kept in this browser, not linked to a file on disk. Open the file once with Open files and readm3 reads and saves it on disk from then on.`); return; } - if (!(await relink([path], true)).length) { notice(`${name} is not linked to a file on disk.`); return; } + saveStatus.textContent = `Looking for ${name} on disk…`; + if (!(await relink([path], true)).length) { notice(`${name} was not found on disk.`); markUnsaved(); 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; @@ -1207,6 +1277,16 @@ function bindEvents() { void versionHistory(cloudDocument, adoptCloud).catch(error => notice(error instanceof Error ? error.message : "Could not load history.")); }; element("reload-disk").onclick = () => void reloadFromDisk(); + const directButton = element("disk-direct"); + directButton.setAttribute("aria-pressed", String(diskDirect())); + directButton.onclick = () => { + const on = !diskDirect(); + try { localStorage.setItem("readm3:disk-direct", on ? "1" : "0"); } catch { /* This session only. */ } + directButton.setAttribute("aria-pressed", String(on)); + notice(on ? "Auto-save to disk is on: edits are written to the file as you type, and files are read from disk when opened." + : "Auto-save to disk is off: edits stay on this device until you press Ctrl+S."); + if (on && diskEntries.has(workspace.active)) { writeThrough(workspace.active); void flushToDisk(); } + }; element("open-files").onclick = () => void openFromDisk(false); element("open-folder").onclick = () => void openFromDisk(true); filesInput.onchange = () => void importFiles(copies(filesInput.files ?? [])); diff --git a/src/cli.ts b/src/cli.ts index 809e6d9..c7f2f35 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.29.0"; +export const VERSION = "0.30.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