From 31a8568a32cbc215bb265cf8243d9347bf39d839 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 30 Sep 2026 13:04:51 -0600 Subject: [PATCH 1/5] fix: remove max width --- src/styles/markdown.css | 134 ++++++++++++++++++++++++++++++---------- 1 file changed, 103 insertions(+), 31 deletions(-) diff --git a/src/styles/markdown.css b/src/styles/markdown.css index 807bbdb..8273c94 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -3,13 +3,19 @@ .markdown-body { @apply text-foreground leading-7; font-size: 0.9375rem; - max-width: 860px; + width: 100%; + max-width: none; + box-sizing: border-box; margin: 0 auto; padding: 2rem 2.5rem 6rem; } -.markdown-body > *:first-child { margin-top: 0; } -.markdown-body > *:last-child { margin-bottom: 0; } +.markdown-body > *:first-child { + margin-top: 0; +} +.markdown-body > *:last-child { + margin-bottom: 0; +} .markdown-body h1, .markdown-body h2, @@ -22,32 +28,76 @@ margin-bottom: 0.75rem; scroll-margin-top: 4rem; } -.markdown-body h1 { font-size: 2rem; line-height: 1.2; border-bottom: 1px solid var(--border); padding-bottom: 0.4em; } -.markdown-body h2 { font-size: 1.5rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } -.markdown-body h3 { font-size: 1.25rem; } -.markdown-body h4 { font-size: 1.05rem; } -.markdown-body h5 { font-size: 0.95rem; @apply text-muted-foreground; } -.markdown-body h6 { font-size: 0.85rem; @apply text-muted-foreground uppercase tracking-wider; } +.markdown-body h1 { + font-size: 2rem; + line-height: 1.2; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4em; +} +.markdown-body h2 { + font-size: 1.5rem; + line-height: 1.3; + border-bottom: 1px solid var(--border); + padding-bottom: 0.3em; +} +.markdown-body h3 { + font-size: 1.25rem; +} +.markdown-body h4 { + font-size: 1.05rem; +} +.markdown-body h5 { + font-size: 0.95rem; + @apply text-muted-foreground; +} +.markdown-body h6 { + font-size: 0.85rem; + @apply text-muted-foreground uppercase tracking-wider; +} -.markdown-body p { margin: 0.75rem 0; } -.markdown-body a { @apply text-primary underline underline-offset-2; } -.markdown-body a:hover { @apply opacity-80; } +.markdown-body p { + margin: 0.75rem 0; +} +.markdown-body a { + @apply text-primary underline underline-offset-2; +} +.markdown-body a:hover { + @apply opacity-80; +} -.markdown-body strong { @apply font-semibold; } -.markdown-body em { font-style: italic; } -.markdown-body del { @apply text-muted-foreground line-through; } +.markdown-body strong { + @apply font-semibold; +} +.markdown-body em { + font-style: italic; +} +.markdown-body del { + @apply text-muted-foreground line-through; +} .markdown-body ul, .markdown-body ol { margin: 0.75rem 0; padding-left: 1.5rem; } -.markdown-body ul { list-style: disc; } -.markdown-body ol { list-style: decimal; } -.markdown-body li { margin: 0.25rem 0; } -.markdown-body li > ul, .markdown-body li > ol { margin: 0.25rem 0; } +.markdown-body ul { + list-style: disc; +} +.markdown-body ol { + list-style: decimal; +} +.markdown-body li { + margin: 0.25rem 0; +} +.markdown-body li > ul, +.markdown-body li > ol { + margin: 0.25rem 0; +} -.markdown-body .task-list-item { list-style: none; margin-left: -1.25rem; } +.markdown-body .task-list-item { + list-style: none; + margin-left: -1.25rem; +} .markdown-body .task-list-item input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; @@ -75,17 +125,23 @@ display: block; overflow-x: auto; } -.markdown-body th, .markdown-body td { +.markdown-body th, +.markdown-body td { border: 1px solid var(--border); padding: 0.5rem 0.75rem; text-align: left; } -.markdown-body th { @apply bg-muted font-semibold; } -.markdown-body tr:nth-child(2n) td { background: color-mix(in oklch, var(--muted) 40%, transparent); } +.markdown-body th { + @apply bg-muted font-semibold; +} +.markdown-body tr:nth-child(2n) td { + background: color-mix(in oklch, var(--muted) 40%, transparent); +} .markdown-body code:not(pre code) { @apply bg-muted text-foreground rounded; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em; padding: 0.12em 0.35em; } @@ -102,7 +158,8 @@ .markdown-body pre.shiki { display: block; padding: 1rem 1.1rem; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.86em; line-height: 1.55; overflow-x: auto; @@ -111,8 +168,13 @@ /* Shiki paints inline color/background on the
 and on every token
     based on the theme passed at render time, so no extra mapping
    needed here. We just keep padding consistent with our own pre rule. */
-.markdown-body pre.shiki { padding: 1rem 1.1rem; }
-.markdown-body pre.shiki code { background: transparent; padding: 0; }
+.markdown-body pre.shiki {
+  padding: 1rem 1.1rem;
+}
+.markdown-body pre.shiki code {
+  background: transparent;
+  padding: 0;
+}
 
 .markdown-body .copy-code-btn {
   position: absolute;
@@ -128,10 +190,17 @@
   opacity: 0;
   transition: opacity 120ms ease;
 }
-.markdown-body pre:hover .copy-code-btn { opacity: 1; }
-.markdown-body .copy-code-btn:hover { color: var(--foreground); }
+.markdown-body pre:hover .copy-code-btn {
+  opacity: 1;
+}
+.markdown-body .copy-code-btn:hover {
+  color: var(--foreground);
+}
 
-.markdown-body img { max-width: 100%; border-radius: 0.4rem; }
+.markdown-body img {
+  max-width: 100%;
+  border-radius: 0.4rem;
+}
 
 .markdown-body .mermaid-block {
   background: var(--muted);
@@ -142,7 +211,10 @@
   text-align: center;
   overflow-x: auto;
 }
-.markdown-body .mermaid-block svg { max-width: 100%; height: auto; }
+.markdown-body .mermaid-block svg {
+  max-width: 100%;
+  height: auto;
+}
 
 .markdown-body mark.doc-search-match {
   background: color-mix(in oklch, var(--muted-foreground) 35%, transparent);

From 0220e462ae1c1d823a7f4f22a0113badc4ada6a0 Mon Sep 17 00:00:00 2001
From: Chris Thompson 
Date: Wed, 30 Sep 2026 13:33:18 -0600
Subject: [PATCH 2/5] task: declare markdown file associations so Marky can be
 the default viewer

Adds bundle.fileAssociations for .md / .markdown / .mdx with role Viewer
and text/markdown MIME so macOS offers Marky as Open With and Linux
.desktop files carry the right MimeType.

Co-Authored-By: Claude Code 
---
 src-tauri/tauri.conf.json | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index ff5d4e8..12ae127 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -34,6 +34,15 @@
       "icons/128x128@2x.png",
       "icons/icon.icns",
       "icons/icon.ico"
+    ],
+    "fileAssociations": [
+      {
+        "ext": ["md", "markdown", "mdx"],
+        "name": "Markdown Document",
+        "role": "Viewer",
+        "mimeType": "text/markdown",
+        "contentTypes": ["net.daringfireball.markdown"]
+      }
     ]
   }
 }

From 0e98099d17d34dc275eb59c70049a9548231d3b3 Mon Sep 17 00:00:00 2001
From: Chris Thompson 
Date: Wed, 30 Sep 2026 13:33:21 -0600
Subject: [PATCH 3/5] task: add a PR check running cargo test on macOS and
 Linux

Adds .github/workflows/check.yml running cargo test on ubuntu-22.04
and macos-latest so Linux-only compile failures show up at PR time
instead of at v* tag push.

Co-Authored-By: Claude Code 
---
 .github/workflows/check.yml | 38 +++++++++++++++++++++++++++++++++++++
 1 file changed, 38 insertions(+)
 create mode 100644 .github/workflows/check.yml

diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..0f93456
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,38 @@
+name: Check
+
+on:
+  pull_request:
+
+jobs:
+  test:
+    strategy:
+      fail-fast: false
+      matrix:
+        runner:
+          - ubuntu-22.04
+          - macos-latest
+    runs-on: ${{ matrix.runner }}
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Install system dependencies (Linux)
+        if: runner.os == 'Linux'
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y \
+            libwebkit2gtk-4.1-dev \
+            libgtk-3-dev \
+            libappindicator3-dev \
+            librsvg2-dev \
+            patchelf \
+            xdg-utils
+
+      - name: Install Rust
+        uses: dtolnay/rust-toolchain@stable
+
+      - name: Stub frontendDist for cargo build
+        run: mkdir -p dist
+
+      - name: Run cargo test
+        working-directory: src-tauri
+        run: cargo test
\ No newline at end of file

From 2e6e2e4b4ec3c0dd61c9b3db72205a777a443bf2 Mon Sep 17 00:00:00 2001
From: Chris Thompson 
Date: Wed, 30 Sep 2026 13:33:16 -0600
Subject: [PATCH 4/5] task: build native menu bar and route menu actions to the
 frontend

Implements the static menu structure (File / Edit / View / Window / Help),
platform-gated app menu, accelerators, and the menu://action listener
that replaces the global keydown effect. Adds menu.rs and folders.ts
with placeholders (TODO(#00N)) so dependent tasks can fill them in.

Co-Authored-By: Claude Code 
---
 src-tauri/src/lib.rs             |   7 +
 src-tauri/src/menu.rs            | 371 +++++++++++++++++++++++++++++++
 src/App.tsx                      | 123 +++++++---
 src/components/FolderSidebar.tsx |   9 +-
 src/lib/folders.ts               |  13 ++
 src/lib/tauri.ts                 |  25 +++
 6 files changed, 505 insertions(+), 43 deletions(-)
 create mode 100644 src-tauri/src/menu.rs
 create mode 100644 src/lib/folders.ts

diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 244ce1b..1cf52c0 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -3,6 +3,7 @@ mod commands;
 mod error;
 mod folder;
 mod fs;
+mod menu;
 mod registry;
 mod search;
 mod settings;
@@ -110,8 +111,14 @@ pub fn run() {
             app.manage(InitialTargetState(Mutex::new(initial_resolved)));
             app.manage(registry);
             app.manage(watchers);
+
+            let (menu, handles) = menu::build(app.handle())?;
+            app.set_menu(menu)?;
+            app.manage(handles);
+
             Ok(())
         })
+        .on_menu_event(menu::handle_event)
         .invoke_handler(tauri::generate_handler![
             commands::get_initial_target,
             commands::set_initial_target,
diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs
new file mode 100644
index 0000000..64b1a43
--- /dev/null
+++ b/src-tauri/src/menu.rs
@@ -0,0 +1,371 @@
+//! Native menu bar: static menu construction, id parsing, and event routing.
+//!
+//! Platform differences (see docs/plans/native-menu-bar.md "Platform gating")
+//! live entirely in this module via `#[cfg(target_os = "macos")]` /
+//! `#[cfg(not(target_os = "macos"))]`. `cfg!()` is only used for label text.
+
+use tauri::menu::{
+    Menu, MenuBuilder, MenuEvent, MenuItem, MenuItemBuilder, Submenu, SubmenuBuilder,
+};
+use tauri::{AppHandle, Emitter, Manager, Wry};
+use tauri_plugin_opener::OpenerExt;
+
+/// Menu items whose enabled state depends on app state (has-file /
+/// has-folder). Stored in Tauri managed state so a later task (#004) can
+/// toggle them without rebuilding the whole menu. The recent-files/folders
+/// submenus are populated by #003.
+pub struct MenuHandles {
+    #[allow(dead_code)]
+    pub file_items: Vec>,
+    #[allow(dead_code)]
+    pub folder_items: Vec>,
+    #[allow(dead_code)]
+    pub recent_files: Submenu,
+    #[allow(dead_code)]
+    pub recent_folders: Submenu,
+}
+
+/// Build the full native menu bar (app, File, Edit, View, Window, Help).
+pub fn build(app: &AppHandle) -> tauri::Result<(Menu, MenuHandles)> {
+    let mut builder = MenuBuilder::new(app);
+
+    #[cfg(target_os = "macos")]
+    {
+        builder = builder.item(&app_menu(app)?);
+    }
+
+    let (file_menu, file_items, folder_items, recent_files, recent_folders) = file_menu(app)?;
+    builder = builder.item(&file_menu).item(&edit_menu(app)?).item(&view_menu(app)?);
+
+    #[cfg(target_os = "macos")]
+    {
+        builder = builder.item(&window_menu(app)?);
+    }
+
+    builder = builder.item(&help_menu(app)?);
+
+    let menu = builder.build()?;
+
+    Ok((
+        menu,
+        MenuHandles {
+            file_items,
+            folder_items,
+            recent_files,
+            recent_folders,
+        },
+    ))
+}
+
+#[cfg(target_os = "macos")]
+fn app_menu(app: &AppHandle) -> tauri::Result> {
+    SubmenuBuilder::new(app, "Marky")
+        .about_with_text("About Marky", None)
+        .separator()
+        .services()
+        .separator()
+        .hide_with_text("Hide Marky")
+        .hide_others()
+        .show_all()
+        .separator()
+        .quit_with_text("Quit Marky")
+        .build()
+}
+
+type FileMenuParts = (
+    Submenu,
+    Vec>,
+    Vec>,
+    Submenu,
+    Submenu,
+);
+
+fn file_menu(app: &AppHandle) -> tauri::Result {
+    let open = MenuItemBuilder::with_id("open", "Open…")
+        .accelerator("CmdOrCtrl+O")
+        .build(app)?;
+    let open_folder = MenuItemBuilder::with_id("open-folder", "Open Folder…")
+        .accelerator("CmdOrCtrl+Shift+O")
+        .build(app)?;
+
+    // Populated by #003; empty and disabled for now.
+    let recent_files = SubmenuBuilder::with_id(app, "recent-files", "Open Recent")
+        .enabled(false)
+        .build()?;
+    let recent_folders = SubmenuBuilder::with_id(app, "recent-folders", "Open Recent Folder")
+        .enabled(false)
+        .build()?;
+
+    let reload_file = MenuItemBuilder::with_id("reload-file", "Reload File")
+        .accelerator("CmdOrCtrl+R")
+        .build(app)?;
+    let rescan_folder = MenuItemBuilder::with_id("rescan-folder", "Rescan Folder")
+        .accelerator("CmdOrCtrl+Shift+R")
+        .build(app)?;
+
+    let export_html = MenuItemBuilder::with_id("export-html", "HTML…")
+        .accelerator("Alt+CmdOrCtrl+E")
+        .build(app)?;
+    let export_markdown = MenuItemBuilder::with_id("export-markdown", "Markdown Copy…")
+        .accelerator("CmdOrCtrl+Shift+S")
+        .build(app)?;
+    let export_menu = SubmenuBuilder::new(app, "Export")
+        .item(&export_html)
+        .item(&export_markdown)
+        .build()?;
+
+    let print = MenuItemBuilder::with_id("print", "Print…")
+        .accelerator("CmdOrCtrl+P")
+        .build(app)?;
+
+    let reveal_label = if cfg!(target_os = "macos") {
+        "Reveal in Finder"
+    } else {
+        "Show in File Manager"
+    };
+    let reveal = MenuItemBuilder::with_id("reveal", reveal_label)
+        .accelerator("Alt+CmdOrCtrl+R")
+        .build(app)?;
+
+    let close_tab = MenuItemBuilder::with_id("close-tab", "Close Tab")
+        .accelerator("CmdOrCtrl+W")
+        .build(app)?;
+    let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder")
+        .accelerator("CmdOrCtrl+Shift+W")
+        .build(app)?;
+
+    let builder = SubmenuBuilder::new(app, "File")
+        .item(&open)
+        .item(&open_folder)
+        .item(&recent_files)
+        .item(&recent_folders)
+        .separator()
+        .item(&reload_file)
+        .item(&rescan_folder)
+        .separator()
+        .item(&export_menu)
+        .item(&print)
+        .separator()
+        .item(&reveal);
+
+    #[cfg(target_os = "macos")]
+    let open_with = MenuItemBuilder::with_id("open-with", "Open With…")
+        .accelerator("Alt+CmdOrCtrl+O")
+        .build(app)?;
+    #[cfg(target_os = "macos")]
+    let builder = builder.item(&open_with);
+
+    let builder = builder.separator().item(&close_tab).item(&close_folder);
+
+    #[cfg(not(target_os = "macos"))]
+    let quit = MenuItemBuilder::with_id("quit", "Quit")
+        .accelerator("Ctrl+Q")
+        .build(app)?;
+    #[cfg(not(target_os = "macos"))]
+    let builder = builder.separator().item(&quit);
+
+    let file_menu = builder.build()?;
+
+    let mut file_items = vec![
+        reload_file,
+        export_html,
+        export_markdown,
+        print,
+        reveal,
+        close_tab,
+    ];
+    #[cfg(target_os = "macos")]
+    file_items.push(open_with);
+
+    let folder_items = vec![rescan_folder, close_folder];
+
+    Ok((
+        file_menu,
+        file_items,
+        folder_items,
+        recent_files,
+        recent_folders,
+    ))
+}
+
+fn edit_menu(app: &AppHandle) -> tauri::Result> {
+    let builder = SubmenuBuilder::new(app, "Edit");
+
+    #[cfg(target_os = "macos")]
+    let builder = builder.undo().redo().separator();
+
+    let find = MenuItemBuilder::with_id("find", "Find…")
+        .accelerator("CmdOrCtrl+F")
+        .build(app)?;
+
+    builder
+        .cut()
+        .copy()
+        .paste()
+        .select_all()
+        .separator()
+        .item(&find)
+        .build()
+}
+
+fn view_menu(app: &AppHandle) -> tauri::Result> {
+    let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette")
+        .accelerator("CmdOrCtrl+K")
+        .build(app)?;
+    let split_right = MenuItemBuilder::with_id("split-right", "Split Right")
+        .accelerator("CmdOrCtrl+\\")
+        .build(app)?;
+    let split_down = MenuItemBuilder::with_id("split-down", "Split Down")
+        .accelerator("CmdOrCtrl+Shift+\\")
+        .build(app)?;
+    let close_split = MenuItemBuilder::with_id("close-split", "Close Split").build(app)?;
+    let zoom_reset = MenuItemBuilder::with_id("zoom-reset", "Actual Size")
+        .accelerator("CmdOrCtrl+0")
+        .build(app)?;
+    let zoom_in = MenuItemBuilder::with_id("zoom-in", "Zoom In")
+        .accelerator("CmdOrCtrl+=")
+        .build(app)?;
+    let zoom_out = MenuItemBuilder::with_id("zoom-out", "Zoom Out")
+        .accelerator("CmdOrCtrl+-")
+        .build(app)?;
+
+    let builder = SubmenuBuilder::new(app, "View")
+        .item(&command_palette)
+        .separator()
+        .item(&split_right)
+        .item(&split_down)
+        .item(&close_split)
+        .separator()
+        .item(&zoom_reset)
+        .item(&zoom_in)
+        .item(&zoom_out)
+        .separator();
+
+    #[cfg(target_os = "macos")]
+    let builder = builder.fullscreen_with_text("Enter Full Screen");
+
+    #[cfg(not(target_os = "macos"))]
+    let builder = {
+        let toggle_fullscreen = MenuItemBuilder::with_id("toggle-fullscreen", "Toggle Full Screen")
+            .accelerator("F11")
+            .build(app)?;
+        builder.item(&toggle_fullscreen)
+    };
+
+    builder.build()
+}
+
+#[cfg(target_os = "macos")]
+fn window_menu(app: &AppHandle) -> tauri::Result> {
+    let submenu = SubmenuBuilder::new(app, "Window")
+        .minimize()
+        .maximize_with_text("Zoom")
+        .build()?;
+    // Makes AppKit add "Bring All to Front" plus the window list for us.
+    submenu.set_as_windows_menu_for_nsapp()?;
+    Ok(submenu)
+}
+
+fn help_menu(app: &AppHandle) -> tauri::Result> {
+    let help_github = MenuItemBuilder::with_id("help-github", "Marky on GitHub").build(app)?;
+    let help_issue = MenuItemBuilder::with_id("help-issue", "Report an Issue").build(app)?;
+
+    let builder = SubmenuBuilder::new(app, "Help");
+
+    #[cfg(not(target_os = "macos"))]
+    let builder = builder.about_with_text("About Marky", None).separator();
+
+    let submenu = builder.item(&help_github).item(&help_issue).build()?;
+
+    #[cfg(target_os = "macos")]
+    submenu.set_as_help_menu_for_nsapp()?;
+
+    Ok(submenu)
+}
+
+/// Parsed intent of a menu id. Most ids are simply forwarded to the frontend
+/// as `menu://action`; a handful are handled entirely in Rust.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum MenuCommand {
+    OpenUrl(&'static str),
+    Quit,
+    ToggleFullscreen,
+    Forward(String),
+}
+
+/// Pure mapping from a menu item id to the command it represents. Kept free
+/// of side effects so it's easy to unit test.
+pub fn parse_menu_id(id: &str) -> MenuCommand {
+    match id {
+        "help-github" => MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky"),
+        "help-issue" => MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky/issues/new"),
+        "quit" => MenuCommand::Quit,
+        "toggle-fullscreen" => MenuCommand::ToggleFullscreen,
+        other => MenuCommand::Forward(other.to_string()),
+    }
+}
+
+/// Route a native menu click. Ids not handled here are forwarded to the
+/// frontend on `menu://action` for `App.tsx` to dispatch.
+pub fn handle_event(app: &AppHandle, event: MenuEvent) {
+    match parse_menu_id(event.id().as_ref()) {
+        MenuCommand::OpenUrl(url) => {
+            if let Err(err) = app.opener().open_url(url, None::<&str>) {
+                eprintln!("menu: failed to open {url}: {err}");
+            }
+        }
+        MenuCommand::Quit => app.exit(0),
+        MenuCommand::ToggleFullscreen => {
+            if let Some(window) = app.get_webview_window("main") {
+                if let Ok(is_fullscreen) = window.is_fullscreen() {
+                    let _ = window.set_fullscreen(!is_fullscreen);
+                }
+            }
+        }
+        MenuCommand::Forward(id) => {
+            let _ = app.emit("menu://action", id);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_help_github() {
+        assert_eq!(
+            parse_menu_id("help-github"),
+            MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky")
+        );
+    }
+
+    #[test]
+    fn parses_help_issue() {
+        assert_eq!(
+            parse_menu_id("help-issue"),
+            MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky/issues/new")
+        );
+    }
+
+    #[test]
+    fn parses_quit() {
+        assert_eq!(parse_menu_id("quit"), MenuCommand::Quit);
+    }
+
+    #[test]
+    fn parses_toggle_fullscreen() {
+        assert_eq!(
+            parse_menu_id("toggle-fullscreen"),
+            MenuCommand::ToggleFullscreen
+        );
+    }
+
+    #[test]
+    fn forwards_unrecognized_ids() {
+        assert_eq!(
+            parse_menu_id("close-tab"),
+            MenuCommand::Forward("close-tab".to_string())
+        );
+    }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 6f4160c..249cf9b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,7 +10,16 @@ import { Pane } from "@/components/Pane";
 import { TableOfContents } from "@/components/TableOfContents";
 import { Toolbar } from "@/components/Toolbar";
 import { CommandPalette } from "@/components/CommandPalette";
-import { tauri, onCliTarget, onFolderChanged, onFileChanged, type AnnotatedFolder } from "@/lib/tauri";
+import {
+  tauri,
+  onCliTarget,
+  onFolderChanged,
+  onFileChanged,
+  onMenuAction,
+  type AnnotatedFolder,
+  type MenuAction,
+} from "@/lib/tauri";
+import { pickAndAddFolder } from "@/lib/folders";
 import {
   createInitialState,
   reduce,
@@ -140,42 +149,6 @@ function AppShell() {
     return pathToFolderId[p] ?? null;
   }
 
-  // Global keyboard shortcuts.
-  useEffect(() => {
-    const onKey = (e: KeyboardEvent) => {
-      const meta = e.metaKey || e.ctrlKey;
-      const k = e.key.toLowerCase();
-      if (meta && k === "k") {
-        e.preventDefault();
-        setPaletteOpen((v) => !v);
-      } else if (meta && k === "o") {
-        e.preventDefault();
-        handlePickFile();
-      } else if (meta && k === "f") {
-        e.preventDefault();
-        setSearchPaneId(state.activePaneId);
-      } else if (meta && e.key === "\\") {
-        e.preventDefault();
-        dispatch({ type: "SPLIT", direction: e.shiftKey ? "horizontal" : "vertical" });
-      } else if (meta && k === "w") {
-        e.preventDefault();
-        const tabId = activePane.activeTabId;
-        if (tabId) dispatch({ type: "CLOSE_TAB", tabId, paneId: activePane.id });
-      } else if (meta && (e.key === "=" || e.key === "+")) {
-        e.preventDefault();
-        zoomIn();
-      } else if (meta && e.key === "-") {
-        e.preventDefault();
-        zoomOut();
-      } else if (meta && k === "0") {
-        e.preventDefault();
-        zoomReset();
-      }
-    };
-    window.addEventListener("keydown", onKey);
-    return () => window.removeEventListener("keydown", onKey);
-  }, [state.activePaneId, activePane.activeTabId, activePane.id, zoomIn, zoomOut, zoomReset]);
-
   // File drop opens as new tab.
   useEffect(() => {
     let unlisten: (() => void) | undefined;
@@ -206,6 +179,82 @@ function AppShell() {
       ?.scrollIntoView({ block: "start" });
   };
 
+  // Route native menu actions. Kept in a ref (updated every render) so the
+  // listener below is registered exactly once and never sees stale state.
+  const menuHandlerRef = useRef<(action: MenuAction) => void>(() => {});
+  menuHandlerRef.current = async (action: MenuAction) => {
+    switch (action) {
+      case "open":
+        handlePickFile();
+        break;
+      case "open-folder": {
+        const folder = await pickAndAddFolder();
+        if (folder) refreshFolders();
+        break;
+      }
+      case "reload-file": {
+        if (activeTab?.filePath) {
+          try {
+            const text = await tauri.readFile(activeTab.filePath);
+            dispatch({ type: "UPDATE_TAB_SOURCE", tabId: activeTab.id, source: text });
+          } catch (err) {
+            console.error("failed to reload file", err);
+          }
+        }
+        break;
+      }
+      case "close-tab": {
+        const tabId = activePane.activeTabId;
+        if (tabId) dispatch({ type: "CLOSE_TAB", tabId, paneId: activePane.id });
+        break;
+      }
+      case "find":
+        setSearchPaneId(state.activePaneId);
+        break;
+      case "command-palette":
+        setPaletteOpen((v) => !v);
+        break;
+      case "split-right":
+        dispatch({ type: "SPLIT", direction: "vertical" });
+        break;
+      case "split-down":
+        dispatch({ type: "SPLIT", direction: "horizontal" });
+        break;
+      case "close-split":
+        handleCloseSplit();
+        break;
+      case "zoom-in":
+        zoomIn();
+        break;
+      case "zoom-out":
+        zoomOut();
+        break;
+      case "zoom-reset":
+        zoomReset();
+        break;
+      case "rescan-folder":
+      case "close-folder":
+        // TODO(#002)
+        break;
+      case "print":
+      case "reveal":
+      case "open-with":
+        // TODO(#005)
+        break;
+      case "export-html":
+      case "export-markdown":
+        // TODO(#006)
+        break;
+    }
+  };
+
+  useEffect(() => {
+    const off = onMenuAction((action) => menuHandlerRef.current(action));
+    return () => {
+      off.then((fn) => fn());
+    };
+  }, []);
+
   const renderPane = (paneId: string) => {
     const pane = state.panes.find((p) => p.id === paneId)!;
     return (
diff --git a/src/components/FolderSidebar.tsx b/src/components/FolderSidebar.tsx
index ee2a500..1785005 100644
--- a/src/components/FolderSidebar.tsx
+++ b/src/components/FolderSidebar.tsx
@@ -1,6 +1,6 @@
 import { useEffect, useState, useCallback } from "react";
-import { open as openDialog } from "@tauri-apps/plugin-dialog";
 import { tauri, onFolderChanged, type AnnotatedFolder, type TreeNode } from "@/lib/tauri";
+import { pickAndAddFolder } from "@/lib/folders";
 import { usePreferences } from "@/lib/preferences";
 import { Button } from "@/components/ui/button";
 import { ScrollArea } from "@/components/ui/scroll-area";
@@ -92,11 +92,8 @@ export function FolderSidebar({ activePath, onOpenFile, onOpenPalette, refreshNo
   }, []);
 
   const handleAdd = async () => {
-    const picked = await openDialog({ directory: true, multiple: false });
-    if (typeof picked === "string") {
-      await tauri.addFolder(picked);
-      await loadAll();
-    }
+    const folder = await pickAndAddFolder();
+    if (folder) await loadAll();
   };
 
   const handleRemove = async (id: string) => {
diff --git a/src/lib/folders.ts b/src/lib/folders.ts
new file mode 100644
index 0000000..a3f7a63
--- /dev/null
+++ b/src/lib/folders.ts
@@ -0,0 +1,13 @@
+import { open as openDialog } from "@tauri-apps/plugin-dialog";
+import { tauri, type Folder } from "@/lib/tauri";
+
+/**
+ * Open the native "choose a folder" dialog and, if the user picks one,
+ * register it with the backend. Shared by the sidebar's + button and the
+ * File ▸ Open Folder… menu action so both go through one code path.
+ */
+export async function pickAndAddFolder(): Promise {
+  const picked = await openDialog({ directory: true, multiple: false });
+  if (typeof picked !== "string") return null;
+  return tauri.addFolder(picked);
+}
diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts
index c5993c7..05cde9f 100644
--- a/src/lib/tauri.ts
+++ b/src/lib/tauri.ts
@@ -73,3 +73,28 @@ export function onFileChanged(cb: (paths: string[]) => void): Promise void): Promise {
   return listen("cli://target", (e) => cb(e.payload));
 }
+
+export type MenuAction =
+  | "open"
+  | "open-folder"
+  | "reload-file"
+  | "rescan-folder"
+  | "export-html"
+  | "export-markdown"
+  | "print"
+  | "reveal"
+  | "open-with"
+  | "close-tab"
+  | "close-folder"
+  | "find"
+  | "command-palette"
+  | "split-right"
+  | "split-down"
+  | "close-split"
+  | "zoom-in"
+  | "zoom-out"
+  | "zoom-reset";
+
+export function onMenuAction(cb: (action: MenuAction) => void): Promise {
+  return listen("menu://action", (e) => cb(e.payload));
+}

From 5aedd79c4db3731ed7ab1afa63686727cb1a108b Mon Sep 17 00:00:00 2001
From: Chris Thompson 
Date: Wed, 30 Sep 2026 13:40:42 -0600
Subject: [PATCH 5/5] docs: changelog entries for slice 1 of native menu bar

Co-Authored-By: Claude Code 
---
 CHANGELOG.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index adcfba2..e2b7207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
 ## [Unreleased]
 
 ### Added
+- Native menu bar (File / Edit / View / Window / Help) with platform-gated accelerators
+- Markdown file associations (`.md` / `.markdown` / `.mdx`) so Marky can be the default viewer
+- PR check running `cargo test` on macOS and Linux
 - Adjustable text size — scales all text app-wide (sidebar, toolbar, command palette, markdown content) via Settings dropdown or Cmd+Plus/Cmd+Minus/Cmd+0
 - Resizable sidebars — drag the edge of the folder sidebar or table of contents to resize; double-click to reset to default
 - Copy contents as markdown