Skip to content
Merged
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
146 changes: 140 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -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<Mutex<Option<Submenu<Wry>>>> = OnceLock::new();

fn recent_slot() -> &'static Mutex<Option<Submenu<Wry>>> {
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<RecentItem>) -> 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).
Expand Down Expand Up @@ -217,18 +288,45 @@ pub fn run(path_arg: Option<String>) {
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())
.plugin(tauri_plugin_fs::init())
.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)?;
Expand All @@ -255,7 +353,10 @@ pub fn run(path_arg: Option<String>) {
.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()?,
Expand All @@ -270,23 +371,56 @@ pub fn run(path_arg: Option<String>) {
.separator()
.items(&[&find])
.build()?,
&SubmenuBuilder::new(app, "View")
.items(&[&toggle_theme])
.build()?,
])
.build()?;

app.set_menu(menu)?;

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 },
);
}
_ => {}
}
});
Expand Down
101 changes: 92 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -191,13 +192,11 @@ async function initTheme(): Promise<void> {

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<void> {
const defaultName = activeFile
? activeFile.split("/").pop()!.replace(/\.md$/i, ".pdf")
: "document.pdf";
Expand All @@ -215,9 +214,9 @@ pdfBtn.addEventListener("click", async () => {
} finally {
document.body.classList.remove("print-mode");
}
});
}

themeToggle.addEventListener("click", async () => {
async function toggleTheme(): Promise<void> {
const activeId = activeThemeId();
const newId = themeCatalog[activeId]?.pair ?? activeId;
currentThemeId = newId;
Expand All @@ -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;

Expand Down Expand Up @@ -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 ---

Expand All @@ -445,6 +454,40 @@ async function loadRootPath(): Promise<string | null> {
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<RecentEntry[]> {
const store = await load(STORE_FILE);
return ((await store.get(RECENT_KEY)) as RecentEntry[]) ?? [];
}

async function syncRecentMenu(entries: RecentEntry[]): Promise<void> {
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<void> {
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<void> {
const store = await load(STORE_FILE);
await store.set(RECENT_KEY, []);
await store.save();
await syncRecentMenu([]);
}

// --- Init ---

openBtn.addEventListener("click", openFolder);
Expand Down Expand Up @@ -1418,13 +1461,38 @@ async function init(): Promise<void> {
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<RecentEntry>("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();
});
appWindow.listen("menu-find", () => {
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.
Expand All @@ -1450,6 +1518,11 @@ async function setRootPath(path: string, fileToOpen?: string): Promise<void> {
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);
Expand All @@ -1465,6 +1538,16 @@ async function openFolder(): Promise<void> {
}
}

async function openFile(): Promise<void> {
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<Entry[]> {
Expand Down
Loading
Loading