diff --git a/README.md b/README.md index b0d8a1a..215a75f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A native macOS Markdown viewer built with [Tauri v2](https://v2.tauri.app/). Bro - **Outline panel** — Auto-generated table of contents (h2/h3) with scroll tracking - **Relative link navigation** — Click `.md` links to navigate between documents - **Dark mode** — Follows macOS system appearance, toggleable manually -- **Session persistence** — Remembers your last opened folder across launches +- **Session persistence** — Remembers your last opened folder *and* document across launches - **Multiple windows** — Open a second folder side by side in its own window (Cmd+N for an empty window, Shift+Cmd+N to pick a folder). Each window keeps its own document, outline and search - **Open single files** — Open `.md` files directly via CLI, Finder "Open With", or drag & drop - **PDF export** — Export the current document as PDF with native rendering diff --git a/docs/guide/features.md b/docs/guide/features.md index b291b15..1e02cb0 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -158,7 +158,11 @@ Click the PDF button in the sidebar header to export the current document as a P ## Session Persistence -The app remembers the last opened folder and restores it on next launch. +The app remembers the last opened folder *and* the exact document you were +reading, and restores both on next launch — instead of falling back to the +folder's README. If that document was deleted, renamed, or moved since, the +folder still opens but with no document selected, rather than silently +substituting the README. ## Open Single Files diff --git a/src/main.ts b/src/main.ts index 424bbcd..e0a0e13 100644 --- a/src/main.ts +++ b/src/main.ts @@ -477,6 +477,7 @@ let scrollObserver: IntersectionObserver | null = null; const STORE_FILE = "settings.json"; const STORE_KEY = "lastFolder"; +const LAST_FILE_KEY = "lastFile"; const RECENT_KEY = "recentEntries"; const RECENT_MAX = 10; @@ -493,6 +494,25 @@ async function loadRootPath(): Promise { return ((await store.get(STORE_KEY)) as string) ?? null; } +async function saveLastFile(path: string): Promise { + const store = await load(STORE_FILE); + await store.set(LAST_FILE_KEY, path); + await store.save(); +} + +async function loadLastFile(): Promise { + const store = await load(STORE_FILE); + return ((await store.get(LAST_FILE_KEY)) as string) ?? null; +} + +// Fire-and-forget: called on every document open, including inside loadFile's +// per-phase timing block, so it must not delay rendering or skew the debug HUD. +function persistLastFile(fullPath: string): void { + void saveLastFile(fullPath).catch((e) => + console.warn("Failed to save last opened file:", e) + ); +} + // --- Recent files/folders --- // State lives here in the store; the native "Open Recent" submenu is rebuilt in // Rust via `update_recent_menu` whenever the list changes. @@ -1580,11 +1600,15 @@ async function init(): Promise { // RunEvent::Opened that fired before our listener was registered, or the // folder handed to a window spawned from the File menu. const pending = await invoke("get_pending_open"); - const view = resolveInitialView(pending, await loadRootPath()); + const savedFolder = await loadRootPath(); + const savedFile = await loadLastFile(); + const view = resolveInitialView(pending, savedFolder, savedFile); if (view.kind === "file") { await openFileFromPath(view.path); } else if (view.kind === "folder") { - await setRootPath(view.path); + // view.file (when present) reopens the exact document last viewed in this + // folder, in place of the README auto-select setRootPath falls back to. + await setRootPath(view.path, view.file); } // "welcome": nothing to do, the empty state is what index.html starts on. } @@ -1850,6 +1874,7 @@ async function loadFile(filePath: string): Promise { slowReadNotice.hidden = !(slowRead && !slowReadDismissed); activeFile = filePath; + persistLastFile(fullPath); emptyState.style.display = "none"; markdownEl.style.display = "block"; contentEl.classList.remove("empty"); diff --git a/src/utils.test.ts b/src/utils.test.ts index 097af1a..b02333e 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -274,41 +274,64 @@ describe("mergeRecent", () => { describe("resolveInitialView", () => { it("opens a pending file", () => { - expect(resolveInitialView({ kind: "file", path: "/docs/a.md" }, null)).toEqual({ + expect( + resolveInitialView({ kind: "file", path: "/docs/a.md" }, null, null) + ).toEqual({ kind: "file", path: "/docs/a.md", }); }); it("opens a pending folder", () => { - expect(resolveInitialView({ kind: "folder", path: "/docs" }, null)).toEqual({ + expect( + resolveInitialView({ kind: "folder", path: "/docs" }, null, null) + ).toEqual({ kind: "folder", path: "/docs", }); }); it("prefers a pending open over the saved folder", () => { - expect(resolveInitialView({ kind: "folder", path: "/docs" }, "/old")).toEqual({ + expect( + resolveInitialView({ kind: "folder", path: "/docs" }, "/old", null) + ).toEqual({ kind: "folder", path: "/docs", }); }); it("shows the welcome screen for a window spawned empty", () => { - expect(resolveInitialView({ kind: "empty" }, "/old")).toEqual({ + expect(resolveInitialView({ kind: "empty" }, "/old", null)).toEqual({ kind: "welcome", }); }); it("restores the saved folder when nothing is pending", () => { - expect(resolveInitialView(null, "/old")).toEqual({ + expect(resolveInitialView(null, "/old", null)).toEqual({ kind: "folder", path: "/old", }); }); it("shows the welcome screen with no pending open and no saved folder", () => { - expect(resolveInitialView(null, null)).toEqual({ kind: "welcome" }); + expect(resolveInitialView(null, null, null)).toEqual({ kind: "welcome" }); + }); + + it("restores the saved file, relative to the saved folder", () => { + expect( + resolveInitialView(null, "/docs", "/docs/guide/api.md") + ).toEqual({ + kind: "folder", + path: "/docs", + file: "guide/api.md", + }); + }); + + it("ignores a saved file that isn't inside the saved folder", () => { + expect(resolveInitialView(null, "/docs", "/other/api.md")).toEqual({ + kind: "folder", + path: "/docs", + }); }); }); diff --git a/src/utils.ts b/src/utils.ts index 4578f02..70e83d0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -104,9 +104,18 @@ export type PendingOpen = /** What a window should display right after init. */ export type InitialView = | { kind: "file"; path: string } - | { kind: "folder"; path: string } + | { kind: "folder"; path: string; file?: string } | { kind: "welcome" }; +/** + * True when `file` sits inside `folder` (as a direct or nested child). + * Guards against a stale/corrupted store where `lastFile` no longer matches + * `lastFolder` — e.g. edited by hand, or left over from an older version. + */ +function isWithinFolder(folder: string, file: string): boolean { + return file.startsWith(`${folder}/`); +} + /** * Decide what a window shows on startup. * @@ -115,17 +124,32 @@ export type InitialView = * a flash of the previous folder. `kind: "empty"` is the marker for a window * spawned by "New Window": it must land on the welcome screen rather than * restoring `lastFolder`, otherwise it would just clone the window it came from. + * + * When a folder is restored, `savedFile` (if it's actually inside that folder) + * is returned as a path relative to it, ready for `setRootPath`'s `fileToOpen` + * — that reopens the exact document instead of falling back to the folder's + * README. */ export function resolveInitialView( pending: PendingOpen | null, - savedFolder: string | null + savedFolder: string | null, + savedFile: string | null ): InitialView { if (pending) { if (pending.kind === "file") return { kind: "file", path: pending.path }; if (pending.kind === "folder") return { kind: "folder", path: pending.path }; return { kind: "welcome" }; } - if (savedFolder) return { kind: "folder", path: savedFolder }; + if (savedFolder) { + if (savedFile && isWithinFolder(savedFolder, savedFile)) { + return { + kind: "folder", + path: savedFolder, + file: savedFile.slice(savedFolder.length + 1), + }; + } + return { kind: "folder", path: savedFolder }; + } return { kind: "welcome" }; }