From 686557fa58b51d6febda161b8c92157141d345e6 Mon Sep 17 00:00:00 2001 From: Lucas Lampietti Date: Thu, 13 Aug 2026 16:12:31 +0200 Subject: [PATCH] feat(session): restore the exact last file, not just the folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session restore only ever persisted `lastFolder`. Every cold start with no pending open (no CLI arg, no Finder "Open With") called setRootPath with no file, which always falls through to autoSelectReadme — so whichever document you were reading, the next launch showed the folder's README instead. A new `lastFile` store key is now written on every successful loadFile, and threaded through the existing resolveInitialView (a relative path, validated to actually sit inside the saved folder — a corrupted or stale store just falls back to the folder-only behavior). Restoring goes through setRootPath(savedFolder, relativeFile) rather than reusing openFileFromPath: the latter derives its root from the file's own immediate parent, which would have silently narrowed the sidebar root for any file nested more than one level deep. If the saved file no longer exists, the existing loadFile error handling already leaves the folder open with nothing selected — no extra fallback logic needed for that case. Out of scope: only the "main" window ever falls through to this restore path; spawned viewer-N windows always get an explicit PendingOpen and are unaffected, matching lastFolder's existing scope. --- README.md | 2 +- docs/guide/features.md | 6 +++++- src/main.ts | 29 +++++++++++++++++++++++++++-- src/utils.test.ts | 35 +++++++++++++++++++++++++++++------ src/utils.ts | 30 +++++++++++++++++++++++++++--- 5 files changed, 89 insertions(+), 13 deletions(-) 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" }; }