feat: populate macOS menu bar with native menus (File, Edit, View, Window) (#77)

* feat: populate macOS menu bar with File, Edit, View, Window menus

Add complete native macOS menu structure with all app actions:
- Laputa menu: About, Settings (Cmd+,), Hide/Quit
- File: New Note (Cmd+N), Quick Open (Cmd+P), Save (Cmd+S), Close Tab (Cmd+W)
- Edit: standard Undo/Redo/Cut/Copy/Paste/Select All
- View: Editor Only (Cmd+1), Editor+Notes (Cmd+2), All Panels (Cmd+3),
  Toggle Inspector, Command Palette (Cmd+K)
- Window: Minimize, Maximize, Close Window

All accelerators registered via Tauri menu API. Menu events dispatched
to frontend via useMenuEvents hook. Save/Close Tab items dynamically
disabled when no note tab is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wrap Enter selection assertion in waitFor for effect re-registration

* fix: resolve rebase conflict markers in menu.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-25 20:05:24 +01:00
committed by GitHub
parent 12e2859946
commit 99b4098f48
8 changed files with 380 additions and 49 deletions

View File

@@ -0,0 +1 @@
{"children":[{"id":"menu-structure","type":"frame","name":"macOS Menu Bar Structure","x":0,"y":0,"width":900,"height":600,"layout":"horizontal","gap":24,"padding":24,"children":[{"id":"app-menu","type":"frame","name":"Laputa Menu","layout":"vertical","gap":4,"padding":12,"fill":"#F7F6F3","cornerRadius":[8,8,8,8],"children":[{"id":"app-title","type":"text","content":"Laputa","fontSize":14,"fontWeight":"bold"},{"id":"app-about","type":"text","content":"About Laputa","fontSize":12},{"id":"app-sep1","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"app-settings","type":"text","content":"Settings... ⌘,","fontSize":12},{"id":"app-sep2","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"app-services","type":"text","content":"Services >","fontSize":12},{"id":"app-sep3","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"app-hide","type":"text","content":"Hide Laputa ⌘H","fontSize":12},{"id":"app-hideothers","type":"text","content":"Hide Others ⌥⌘H","fontSize":12},{"id":"app-showall","type":"text","content":"Show All","fontSize":12},{"id":"app-sep4","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"app-quit","type":"text","content":"Quit Laputa ⌘Q","fontSize":12}]},{"id":"file-menu","type":"frame","name":"File Menu","layout":"vertical","gap":4,"padding":12,"fill":"#F7F6F3","cornerRadius":[8,8,8,8],"children":[{"id":"file-title","type":"text","content":"File","fontSize":14,"fontWeight":"bold"},{"id":"file-new","type":"text","content":"New Note ⌘N","fontSize":12},{"id":"file-quickopen","type":"text","content":"Quick Open ⌘P","fontSize":12},{"id":"file-sep1","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"file-save","type":"text","content":"Save ⌘S","fontSize":12},{"id":"file-sep2","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"file-close","type":"text","content":"Close Tab ⌘W","fontSize":12}]},{"id":"edit-menu","type":"frame","name":"Edit Menu","layout":"vertical","gap":4,"padding":12,"fill":"#F7F6F3","cornerRadius":[8,8,8,8],"children":[{"id":"edit-title","type":"text","content":"Edit","fontSize":14,"fontWeight":"bold"},{"id":"edit-undo","type":"text","content":"Undo ⌘Z","fontSize":12},{"id":"edit-redo","type":"text","content":"Redo ⇧⌘Z","fontSize":12},{"id":"edit-sep1","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"edit-cut","type":"text","content":"Cut ⌘X","fontSize":12},{"id":"edit-copy","type":"text","content":"Copy ⌘C","fontSize":12},{"id":"edit-paste","type":"text","content":"Paste ⌘V","fontSize":12},{"id":"edit-sep2","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"edit-selectall","type":"text","content":"Select All ⌘A","fontSize":12}]},{"id":"view-menu","type":"frame","name":"View Menu","layout":"vertical","gap":4,"padding":12,"fill":"#F7F6F3","cornerRadius":[8,8,8,8],"children":[{"id":"view-title","type":"text","content":"View","fontSize":14,"fontWeight":"bold"},{"id":"view-editor","type":"text","content":"Editor Only ⌘1","fontSize":12},{"id":"view-editorlist","type":"text","content":"Editor + Notes ⌘2","fontSize":12},{"id":"view-all","type":"text","content":"All Panels ⌘3","fontSize":12},{"id":"view-sep1","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"view-inspector","type":"text","content":"Toggle Inspector","fontSize":12},{"id":"view-sep2","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"view-cmdpalette","type":"text","content":"Command Palette ⌘K","fontSize":12}]},{"id":"window-menu","type":"frame","name":"Window Menu","layout":"vertical","gap":4,"padding":12,"fill":"#F7F6F3","cornerRadius":[8,8,8,8],"children":[{"id":"window-title","type":"text","content":"Window","fontSize":14,"fontWeight":"bold"},{"id":"window-minimize","type":"text","content":"Minimize ⌘M","fontSize":12},{"id":"window-zoom","type":"text","content":"Zoom","fontSize":12},{"id":"window-sep1","type":"text","content":"---","fontSize":10,"color":"#CCC"},{"id":"window-bringall","type":"text","content":"Bring All to Front","fontSize":12}]}]}],"variables":{}}

View File

@@ -109,6 +109,12 @@ fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
Ok(())
}
#[tauri::command]
fn get_settings() -> Result<Settings, String> {
settings::get_settings()
@@ -238,6 +244,7 @@ pub fn run() {
purge_trash,
migrate_is_a_to_type,
get_settings,
update_menu_state,
save_settings,
github_list_repos,
github_create_repo,

View File

@@ -1,48 +1,210 @@
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
App, Emitter,
menu::{MenuBuilder, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
App, AppHandle, Emitter,
};
const VIEW_ITEMS: [(&str, &str, &str); 3] = [
("view-editor-only", "Editor Only", "CmdOrCtrl+1"),
("view-editor-list", "Editor + Notes", "CmdOrCtrl+2"),
("view-all", "All Panels", "CmdOrCtrl+3"),
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
const FILE_CLOSE_TAB: &str = "file-close-tab";
const VIEW_EDITOR_ONLY: &str = "view-editor-only";
const VIEW_EDITOR_LIST: &str = "view-editor-list";
const VIEW_ALL: &str = "view-all";
const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector";
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
FILE_NEW_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_INSPECTOR,
VIEW_COMMAND_PALETTE,
];
pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let edit_submenu = SubmenuBuilder::new(app, "Edit")
.item(&PredefinedMenuItem::undo(app, Some("Undo"))?)
.item(&PredefinedMenuItem::redo(app, Some("Redo"))?)
.separator()
.item(&PredefinedMenuItem::cut(app, Some("Cut"))?)
.item(&PredefinedMenuItem::copy(app, Some("Copy"))?)
.item(&PredefinedMenuItem::paste(app, Some("Paste"))?)
.item(&PredefinedMenuItem::select_all(app, Some("Select All"))?)
.build()?;
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB];
let mut view_menu = SubmenuBuilder::new(app, "View");
for (id, label, accel) in &VIEW_ITEMS {
let item = MenuItemBuilder::new(*label)
.id(*id)
.accelerator(*accel)
.build(app)?;
view_menu = view_menu.item(&item);
}
let view_submenu = view_menu.build()?;
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
fn build_app_menu(app: &App) -> MenuResult {
let settings_item = MenuItemBuilder::new("Settings...")
.id(APP_SETTINGS)
.accelerator("CmdOrCtrl+,")
.build(app)?;
Ok(SubmenuBuilder::new(app, "Laputa")
.about(None)
.separator()
.item(&settings_item)
.separator()
.services()
.separator()
.hide()
.hide_others()
.show_all()
.separator()
.quit()
.build()?)
}
fn build_file_menu(app: &App) -> MenuResult {
let new_note = MenuItemBuilder::new("New Note")
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let quick_open = MenuItemBuilder::new("Quick Open")
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let save = MenuItemBuilder::new("Save")
.id(FILE_SAVE)
.accelerator("CmdOrCtrl+S")
.build(app)?;
let close_tab = MenuItemBuilder::new("Close Tab")
.id(FILE_CLOSE_TAB)
.accelerator("CmdOrCtrl+W")
.build(app)?;
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&quick_open)
.separator()
.item(&save)
.separator()
.item(&close_tab)
.build()?)
}
fn build_edit_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.build()?)
}
fn build_view_menu(app: &App) -> MenuResult {
let editor_only = MenuItemBuilder::new("Editor Only")
.id(VIEW_EDITOR_ONLY)
.accelerator("CmdOrCtrl+1")
.build(app)?;
let editor_list = MenuItemBuilder::new("Editor + Notes")
.id(VIEW_EDITOR_LIST)
.accelerator("CmdOrCtrl+2")
.build(app)?;
let all_panels = MenuItemBuilder::new("All Panels")
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
let toggle_inspector = MenuItemBuilder::new("Toggle Inspector")
.id(VIEW_TOGGLE_INSPECTOR)
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)
.accelerator("CmdOrCtrl+K")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_inspector)
.separator()
.item(&command_palette)
.build()?)
}
fn build_window_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?)
}
pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let app_menu = build_app_menu(app)?;
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
let view_menu = build_view_menu(app)?;
let window_menu = build_window_menu(app)?;
let menu = MenuBuilder::new(app)
.item(&edit_submenu)
.item(&view_submenu)
.item(&app_menu)
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&window_menu)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
if id.starts_with("view-") {
if CUSTOM_IDS.contains(&id) {
let _ = app_handle.emit("menu-event", id);
}
});
Ok(())
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in NOTE_DEPENDENT_IDS {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) {
let _ = mi.set_enabled(enabled);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_ids_include_all_expected_items() {
let expected = [
"app-settings",
"file-new-note",
"file-quick-open",
"file-save",
"file-close-tab",
"view-editor-only",
"view-editor-list",
"view-all",
"view-toggle-inspector",
"view-command-palette",
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
}
}
#[test]
fn note_dependent_ids_are_subset_of_custom_ids() {
for id in NOTE_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"note-dependent ID {id} not in CUSTOM_IDS"
);
}
}
}

View File

@@ -201,7 +201,7 @@ describe('SearchPanel', () => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
// Wait for the results state to propagate to the keydown handler (useEffect re-run)
// Wait for the effect to re-register with new results before firing Enter
fireEvent.keyDown(window, { key: 'Enter' })
await waitFor(() => {

View File

@@ -2,6 +2,7 @@ import { useAppKeyboard } from './useAppKeyboard'
import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { SidebarSelection, VaultEntry } from '../types'
import type { ViewMode } from './useViewMode'
@@ -39,7 +40,7 @@ interface AppCommandsConfig {
canGoForward?: boolean
}
/** Sets up keyboard shortcuts, command registry, and keyboard navigation. */
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useAppKeyboard({
onQuickOpen: config.onQuickOpen,
@@ -57,6 +58,19 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
handleCloseTabRef: config.handleCloseTabRef,
})
useMenuEvents({
onSetViewMode: config.onSetViewMode,
onCreateNote: config.onCreateNote,
onQuickOpen: config.onQuickOpen,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onToggleInspector: config.onToggleInspector,
onCommandPalette: config.onCommandPalette,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
})
const commands = useCommandRegistry({
activeTabPath: config.activeTabPath,
entries: config.entries,

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from 'vitest'
import { dispatchMenuEvent, type MenuEventHandlers } from './useMenuEvents'
function makeHandlers(): MenuEventHandlers {
return {
onSetViewMode: vi.fn(),
onCreateNote: vi.fn(),
onQuickOpen: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onToggleInspector: vi.fn(),
onCommandPalette: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
}
}
describe('dispatchMenuEvent', () => {
it('view-editor-only sets editor-only mode', () => {
const h = makeHandlers()
dispatchMenuEvent('view-editor-only', h)
expect(h.onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('view-editor-list sets editor-list mode', () => {
const h = makeHandlers()
dispatchMenuEvent('view-editor-list', h)
expect(h.onSetViewMode).toHaveBeenCalledWith('editor-list')
})
it('view-all sets all mode', () => {
const h = makeHandlers()
dispatchMenuEvent('view-all', h)
expect(h.onSetViewMode).toHaveBeenCalledWith('all')
})
it('file-new-note triggers create note', () => {
const h = makeHandlers()
dispatchMenuEvent('file-new-note', h)
expect(h.onCreateNote).toHaveBeenCalled()
})
it('file-quick-open triggers quick open', () => {
const h = makeHandlers()
dispatchMenuEvent('file-quick-open', h)
expect(h.onQuickOpen).toHaveBeenCalled()
})
it('file-save triggers save', () => {
const h = makeHandlers()
dispatchMenuEvent('file-save', h)
expect(h.onSave).toHaveBeenCalled()
})
it('file-close-tab closes the active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('file-close-tab', h)
expect(h.handleCloseTabRef.current).toHaveBeenCalledWith('/vault/test.md')
})
it('file-close-tab does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('file-close-tab', h)
expect(h.handleCloseTabRef.current).not.toHaveBeenCalled()
})
it('app-settings triggers open settings', () => {
const h = makeHandlers()
dispatchMenuEvent('app-settings', h)
expect(h.onOpenSettings).toHaveBeenCalled()
})
it('view-toggle-inspector triggers toggle inspector', () => {
const h = makeHandlers()
dispatchMenuEvent('view-toggle-inspector', h)
expect(h.onToggleInspector).toHaveBeenCalled()
})
it('view-command-palette triggers command palette', () => {
const h = makeHandlers()
dispatchMenuEvent('view-command-palette', h)
expect(h.onCommandPalette).toHaveBeenCalled()
})
it('unknown event ID does nothing', () => {
const h = makeHandlers()
dispatchMenuEvent('unknown-event', h)
expect(h.onSetViewMode).not.toHaveBeenCalled()
expect(h.onCreateNote).not.toHaveBeenCalled()
expect(h.onSave).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { ViewMode } from './useViewMode'
export interface MenuEventHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
onQuickOpen: () => void
onSave: () => void
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
}
const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-editor-only': 'editor-only',
'view-editor-list': 'editor-list',
'view-all': 'all',
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
const viewMode = VIEW_MODE_MAP[id]
if (viewMode) { h.onSetViewMode(viewMode); return }
switch (id) {
case 'file-new-note': h.onCreateNote(); break
case 'file-quick-open': h.onQuickOpen(); break
case 'file-save': h.onSave(); break
case 'file-close-tab': {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
break
}
case 'app-settings': h.onOpenSettings(); break
case 'view-toggle-inspector': h.onToggleInspector(); break
case 'view-command-palette': h.onCommandPalette(); break
}
}
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
export function useMenuEvents(handlers: MenuEventHandlers) {
const ref = useRef(handlers)
ref.current = handlers
// Subscribe once to Tauri menu events
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | undefined
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<string>('menu-event', (event) => {
dispatchMenuEvent(event.payload, ref.current)
})
cleanup = () => { unlisten.then(fn => fn()) }
}).catch(() => { /* not in Tauri */ })
return () => cleanup?.()
}, [])
// Sync menu item enabled state when active tab changes
useEffect(() => {
if (!isTauri()) return
import('@tauri-apps/api/core').then(({ invoke }) => {
invoke('update_menu_state', { hasActiveNote: handlers.activeTabPath !== null })
}).catch(() => {})
}, [handlers.activeTabPath])
}

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect } from 'react'
import { isTauri } from '../mock-tauri'
import { useState, useCallback } from 'react'
export type ViewMode = 'editor-only' | 'editor-list' | 'all'
@@ -24,22 +23,5 @@ export function useViewMode() {
const sidebarVisible = viewMode === 'all'
const noteListVisible = viewMode === 'all' || viewMode === 'editor-list'
// Listen for Tauri menu events
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | undefined
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<string>('menu-event', (event) => {
if (event.payload === 'view-editor-only') setViewMode('editor-only')
else if (event.payload === 'view-editor-list') setViewMode('editor-list')
else if (event.payload === 'view-all') setViewMode('all')
})
cleanup = () => { unlisten.then((fn) => fn()) }
}).catch(() => { /* not in Tauri */ })
return () => cleanup?.()
}, [setViewMode])
return { viewMode, setViewMode, sidebarVisible, noteListVisible }
}