Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 27 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -493,6 +494,25 @@ async function loadRootPath(): Promise<string | null> {
return ((await store.get(STORE_KEY)) as string) ?? null;
}

async function saveLastFile(path: string): Promise<void> {
const store = await load(STORE_FILE);
await store.set(LAST_FILE_KEY, path);
await store.save();
}

async function loadLastFile(): Promise<string | null> {
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.
Expand Down Expand Up @@ -1580,11 +1600,15 @@ async function init(): Promise<void> {
// 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<PendingOpen | null>("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.
}
Expand Down Expand Up @@ -1850,6 +1874,7 @@ async function loadFile(filePath: string): Promise<void> {
slowReadNotice.hidden = !(slowRead && !slowReadDismissed);

activeFile = filePath;
persistLastFile(fullPath);
emptyState.style.display = "none";
markdownEl.style.display = "block";
contentEl.classList.remove("empty");
Expand Down
35 changes: 29 additions & 6 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});

Expand Down
30 changes: 27 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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" };
}

Expand Down