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:
1
design/macos-menu-bar.pen
Normal file
1
design/macos-menu-bar.pen
Normal 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":{}}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
src/hooks/useMenuEvents.test.ts
Normal file
94
src/hooks/useMenuEvents.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
71
src/hooks/useMenuEvents.ts
Normal file
71
src/hooks/useMenuEvents.ts
Normal 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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user