From 012336a0e7d289e163582603a205d77ea41a928a Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre Dupuy Date: Wed, 1 Jul 2026 22:39:11 +0200 Subject: [PATCH] feat(menu): add native File/View menus with Open Recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the native menu to match standard macOS apps: - File: Open File… (⌘O), Open Folder… (⇧⌘O), Open Recent ▸, Print… (⌘P), Export as PDF… (⇧⌘S), Close Window - View: Toggle Dark Mode Print / Export PDF / theme toggle reuse the existing frontend handlers (extracted into named functions), so the titlebar buttons and menu items share one code path. Open Recent is dynamic: recents state lives in the frontend store (recentEntries, capped at 10, deduped by path, most-recent-first) and is pushed to a Rust-owned submenu via a new update_recent_menu command. Entries are recorded on top-level opens (picker, file association, CLI, restore), not on sidebar navigation. Item ids embed the path behind a fixed prefix so paths with ':' survive. The pure list-merge logic is extracted to utils.mergeRecent with tests. Deferred (documented in the PR): New Tab (needs a tabbing system), Save (no-op for a read-only viewer), Share (native sharing picker). Closes #3 --- src-tauri/src/lib.rs | 146 +++++++++++++++++++++++++++++++++++++++++-- src/main.ts | 101 +++++++++++++++++++++++++++--- src/utils.test.ts | 38 +++++++++++ src/utils.ts | 14 +++++ 4 files changed, 284 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a24ee9a..f7bb751 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::sync::{Mutex, OnceLock}; -use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -use tauri::{Emitter, Manager}; +use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder}; +use tauri::{Emitter, Manager, Wry}; #[derive(Clone, serde::Serialize)] #[serde(tag = "kind", rename_all = "lowercase")] @@ -10,6 +10,77 @@ enum PendingOpen { Folder { path: String }, } +#[derive(serde::Deserialize)] +struct RecentItem { + path: String, + kind: String, + label: String, +} + +#[derive(Clone, serde::Serialize)] +struct RecentOpen { + kind: String, + path: String, +} + +// Handle to the "Open Recent" submenu, stored so the frontend can rebuild its +// contents at runtime (via `update_recent_menu`) as the recents list changes. +// The menu itself is owned by Rust; the recents *state* lives in the frontend +// store, keeping state ownership on the TS side per the app's conventions. +static RECENT_SUBMENU: OnceLock>>> = OnceLock::new(); + +fn recent_slot() -> &'static Mutex>> { + RECENT_SUBMENU.get_or_init(|| Mutex::new(None)) +} + +// Menu item ids for recent entries embed the path after a fixed prefix, so the +// menu-event handler can recover the path even if it contains a colon. +const RECENT_FILE_PREFIX: &str = "recent-file:"; +const RECENT_FOLDER_PREFIX: &str = "recent-folder:"; + +#[tauri::command] +fn update_recent_menu(app: tauri::AppHandle, items: Vec) -> Result<(), String> { + let guard = recent_slot().lock().map_err(|e| e.to_string())?; + let Some(submenu) = guard.as_ref() else { + return Ok(()); // menu not built yet — nothing to update + }; + + let count = submenu.items().map_err(|e| e.to_string())?.len(); + for _ in 0..count { + submenu.remove_at(0).map_err(|e| e.to_string())?; + } + + if items.is_empty() { + let none = MenuItemBuilder::with_id("recent_none", "No Recent Files") + .enabled(false) + .build(&app) + .map_err(|e| e.to_string())?; + submenu.append(&none).map_err(|e| e.to_string())?; + return Ok(()); + } + + for item in &items { + let prefix = if item.kind == "folder" { + RECENT_FOLDER_PREFIX + } else { + RECENT_FILE_PREFIX + }; + let id = format!("{prefix}{}", item.path); + let mi = MenuItemBuilder::with_id(id, &item.label) + .build(&app) + .map_err(|e| e.to_string())?; + submenu.append(&mi).map_err(|e| e.to_string())?; + } + + let sep = PredefinedMenuItem::separator(&app).map_err(|e| e.to_string())?; + submenu.append(&sep).map_err(|e| e.to_string())?; + let clear = MenuItemBuilder::with_id("recent_clear", "Clear Menu") + .build(&app) + .map_err(|e| e.to_string())?; + submenu.append(&clear).map_err(|e| e.to_string())?; + Ok(()) +} + // Global slot — available from process start, so `RunEvent::Opened` can write // safely even if it fires before `setup` finishes (which can happen on macOS // cold-start via Apple Events). @@ -217,7 +288,8 @@ pub fn run(path_arg: Option) { list_disk_themes, save_disk_theme, delete_disk_theme, - reveal_themes_dir + reveal_themes_dir, + update_recent_menu ]) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) @@ -225,10 +297,36 @@ pub fn run(path_arg: Option) { .plugin(tauri_plugin_opener::init()) .setup(move |app| { // Build native menu - let open_folder = MenuItemBuilder::with_id("open_folder", "Open Folder…") + let open_file = MenuItemBuilder::with_id("open_file", "Open File…") .accelerator("CmdOrCtrl+O") .build(app)?; + let open_folder = MenuItemBuilder::with_id("open_folder", "Open Folder…") + .accelerator("CmdOrCtrl+Shift+O") + .build(app)?; + + let recent_submenu = SubmenuBuilder::new(app, "Open Recent") + .item( + &MenuItemBuilder::with_id("recent_none", "No Recent Files") + .enabled(false) + .build(app)?, + ) + .build()?; + if let Ok(mut slot) = recent_slot().lock() { + *slot = Some(recent_submenu.clone()); + } + + let print_item = MenuItemBuilder::with_id("print", "Print…") + .accelerator("CmdOrCtrl+P") + .build(app)?; + + let export_pdf_item = MenuItemBuilder::with_id("export_pdf", "Export as PDF…") + .accelerator("CmdOrCtrl+Shift+S") + .build(app)?; + + let toggle_theme = MenuItemBuilder::with_id("toggle_theme", "Toggle Dark Mode") + .build(app)?; + let preferences = MenuItemBuilder::with_id("preferences", "Preferences…") .accelerator("CmdOrCtrl+,") .build(app)?; @@ -255,7 +353,10 @@ pub fn run(path_arg: Option) { .quit() .build()?, &SubmenuBuilder::new(app, "File") - .items(&[&open_folder]) + .items(&[&open_file, &open_folder]) + .item(&recent_submenu) + .separator() + .items(&[&print_item, &export_pdf_item]) .separator() .close_window() .build()?, @@ -270,6 +371,9 @@ pub fn run(path_arg: Option) { .separator() .items(&[&find]) .build()?, + &SubmenuBuilder::new(app, "View") + .items(&[&toggle_theme]) + .build()?, ]) .build()?; @@ -277,16 +381,46 @@ pub fn run(path_arg: Option) { let app_handle = app.handle().clone(); app.on_menu_event(move |_app, event| { - match event.id().0.as_str() { + let id = event.id().0.as_str(); + match id { + "open_file" => { + let _ = app_handle.emit("menu-open-file", ()); + } "open_folder" => { let _ = app_handle.emit("menu-open-folder", ()); } + "print" => { + let _ = app_handle.emit("menu-print", ()); + } + "export_pdf" => { + let _ = app_handle.emit("menu-export-pdf", ()); + } + "toggle_theme" => { + let _ = app_handle.emit("menu-toggle-theme", ()); + } "preferences" => { let _ = app_handle.emit("menu-open-preferences", ()); } "find" => { let _ = app_handle.emit("menu-find", ()); } + "recent_clear" => { + let _ = app_handle.emit("menu-clear-recent", ()); + } + _ if id.starts_with(RECENT_FILE_PREFIX) => { + let path = id[RECENT_FILE_PREFIX.len()..].to_string(); + let _ = app_handle.emit( + "menu-open-recent", + RecentOpen { kind: "file".into(), path }, + ); + } + _ if id.starts_with(RECENT_FOLDER_PREFIX) => { + let path = id[RECENT_FOLDER_PREFIX.len()..].to_string(); + let _ = app_handle.emit( + "menu-open-recent", + RecentOpen { kind: "folder".into(), path }, + ); + } _ => {} } }); diff --git a/src/main.ts b/src/main.ts index 9343d51..eadfe01 100644 --- a/src/main.ts +++ b/src/main.ts @@ -72,13 +72,14 @@ import { toHexForPicker, } from "./theme-editor"; import { createSearchController, type SearchController } from "./search"; -import type { Entry } from "./utils"; +import type { Entry, RecentEntry } from "./utils"; import { classifyLink, extractRootName, filterAndSortEntries, findReadme, getFullPath, + mergeRecent, parseMarkdownHref, resolvePath, } from "./utils"; @@ -191,13 +192,11 @@ async function initTheme(): Promise { import { invoke } from "@tauri-apps/api/core"; -const printBtn = document.getElementById("print-btn") as HTMLButtonElement; -printBtn.addEventListener("click", () => { +function printDocument(): void { invoke("print_webview"); -}); +} -const pdfBtn = document.getElementById("pdf-btn") as HTMLButtonElement; -pdfBtn.addEventListener("click", async () => { +async function exportPdf(): Promise { const defaultName = activeFile ? activeFile.split("/").pop()!.replace(/\.md$/i, ".pdf") : "document.pdf"; @@ -215,9 +214,9 @@ pdfBtn.addEventListener("click", async () => { } finally { document.body.classList.remove("print-mode"); } -}); +} -themeToggle.addEventListener("click", async () => { +async function toggleTheme(): Promise { const activeId = activeThemeId(); const newId = themeCatalog[activeId]?.pair ?? activeId; currentThemeId = newId; @@ -229,7 +228,15 @@ themeToggle.addEventListener("click", async () => { await store.save(); syncPrefsUI(); refreshThemeGridSelection(); -}); +} + +const printBtn = document.getElementById("print-btn") as HTMLButtonElement; +printBtn.addEventListener("click", printDocument); + +const pdfBtn = document.getElementById("pdf-btn") as HTMLButtonElement; +pdfBtn.addEventListener("click", exportPdf); + +themeToggle.addEventListener("click", toggleTheme); let mermaidCounter = 0; @@ -431,6 +438,8 @@ let scrollObserver: IntersectionObserver | null = null; const STORE_FILE = "settings.json"; const STORE_KEY = "lastFolder"; +const RECENT_KEY = "recentEntries"; +const RECENT_MAX = 10; // --- Store persistence --- @@ -445,6 +454,40 @@ async function loadRootPath(): Promise { return ((await store.get(STORE_KEY)) as string) ?? null; } +// --- 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. + +async function loadRecents(): Promise { + const store = await load(STORE_FILE); + return ((await store.get(RECENT_KEY)) as RecentEntry[]) ?? []; +} + +async function syncRecentMenu(entries: RecentEntry[]): Promise { + const items = entries.map((e) => ({ + path: e.path, + kind: e.kind, + label: e.path.split("/").pop() || e.path, + })); + await invoke("update_recent_menu", { items }); +} + +async function recordRecent(path: string, kind: "file" | "folder"): Promise { + const store = await load(STORE_FILE); + const list = ((await store.get(RECENT_KEY)) as RecentEntry[]) ?? []; + const next = mergeRecent(list, { path, kind }, RECENT_MAX); + await store.set(RECENT_KEY, next); + await store.save(); + await syncRecentMenu(next); +} + +async function clearRecents(): Promise { + const store = await load(STORE_FILE); + await store.set(RECENT_KEY, []); + await store.save(); + await syncRecentMenu([]); +} + // --- Init --- openBtn.addEventListener("click", openFolder); @@ -1418,6 +1461,28 @@ async function init(): Promise { appWindow.listen("menu-open-folder", () => { openFolder(); }); + appWindow.listen("menu-open-file", () => { + openFile(); + }); + appWindow.listen("menu-print", () => { + printDocument(); + }); + appWindow.listen("menu-export-pdf", () => { + exportPdf(); + }); + appWindow.listen("menu-toggle-theme", () => { + toggleTheme(); + }); + appWindow.listen("menu-open-recent", (event) => { + if (event.payload.kind === "file") { + openFileFromPath(event.payload.path); + } else { + setRootPath(event.payload.path); + } + }); + appWindow.listen("menu-clear-recent", () => { + clearRecents(); + }); appWindow.listen("menu-open-preferences", () => { openPrefs(); }); @@ -1425,6 +1490,9 @@ async function init(): Promise { focusSearch(); }); + // Populate the native "Open Recent" submenu from the persisted list. + await syncRecentMenu(await loadRecents()); + // Cold-start: pull anything the backend buffered (CLI arg or RunEvent::Opened // that fired before our listener was registered). A pending open wins over // the saved folder so the user doesn't see a flash of the previous folder. @@ -1450,6 +1518,11 @@ async function setRootPath(path: string, fileToOpen?: string): Promise { currentPath = []; activeFile = null; await saveRootPath(path); + if (fileToOpen) { + await recordRecent(`${path}/${fileToOpen}`, "file"); + } else { + await recordRecent(path, "folder"); + } await renderSidebar(); if (fileToOpen) { await loadFile(fileToOpen); @@ -1465,6 +1538,16 @@ async function openFolder(): Promise { } } +async function openFile(): Promise { + const selected = await open({ + multiple: false, + filters: [{ name: "Markdown", extensions: ["md", "markdown", "mdx"] }], + }); + if (typeof selected === "string") { + await openFileFromPath(selected); + } +} + // --- Filesystem --- async function listEntries(dirPath: string): Promise { diff --git a/src/utils.test.ts b/src/utils.test.ts index ad97a29..0688e87 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -7,7 +7,9 @@ import { classifyLink, parseMarkdownHref, findReadme, + mergeRecent, } from "./utils"; +import type { RecentEntry } from "./utils"; describe("resolvePath", () => { it("resolves a simple relative path", () => { @@ -231,3 +233,39 @@ describe("findReadme", () => { expect(findReadme(entries)).toBeUndefined(); }); }); + +describe("mergeRecent", () => { + const f = (path: string): RecentEntry => ({ path, kind: "file" }); + + it("prepends the new entry", () => { + expect(mergeRecent([f("/a"), f("/b")], f("/c"), 10)).toEqual([ + f("/c"), + f("/a"), + f("/b"), + ]); + }); + + it("moves an existing path to the front without duplicating", () => { + expect(mergeRecent([f("/a"), f("/b"), f("/c")], f("/c"), 10)).toEqual([ + f("/c"), + f("/a"), + f("/b"), + ]); + }); + + it("caps the list at max, dropping the oldest", () => { + const result = mergeRecent([f("/a"), f("/b"), f("/c")], f("/d"), 3); + expect(result).toEqual([f("/d"), f("/a"), f("/b")]); + }); + + it("dedupes by path regardless of kind", () => { + const folder: RecentEntry = { path: "/a", kind: "folder" }; + expect(mergeRecent([f("/a")], folder, 10)).toEqual([folder]); + }); + + it("does not mutate the input list", () => { + const list = [f("/a")]; + mergeRecent(list, f("/b"), 10); + expect(list).toEqual([f("/a")]); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 8c74d94..f7312d5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,3 +94,17 @@ export function findReadme(entries: Entry[]): Entry | undefined { (e) => e.kind === "file" && e.name.toLowerCase() === "readme.md" ); } + +export type RecentEntry = { path: string; kind: "file" | "folder" }; + +/** + * Prepend an entry to the recents list, dropping any prior entry with the same + * path (most-recent-wins) and capping the list length. + */ +export function mergeRecent( + list: RecentEntry[], + entry: RecentEntry, + max: number +): RecentEntry[] { + return [entry, ...list.filter((e) => e.path !== entry.path)].slice(0, max); +}