From a966b2cc650ffcd7e3227724b98195d851a91fb4 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 5 Mar 2026 14:13:54 +0100 Subject: [PATCH] feat: reorganize menu bar with Go, Note, and Vault menus Add missing command palette commands to menu bar and reorganize into logical groups: File, Edit, View, Go, Note, Vault, Window. New menus expose navigation filters, note actions, vault management, themes, and git operations. Context-sensitive items (archive, trash, diff, raw editor, commit, conflicts) are greyed out when not applicable. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 13 +- src-tauri/src/menu.rs | 323 +++++++++++++++++++++++++++----- src/hooks/useAppCommands.ts | 32 +++- src/hooks/useMenuEvents.test.ts | 154 ++++++++++++++- src/hooks/useMenuEvents.ts | 76 +++++++- src/types.ts | 4 +- 6 files changed, 530 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c7b70d2..025d225b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -303,8 +303,19 @@ fn batch_trash_notes(paths: Vec) -> Result { } #[tauri::command] -fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> { +fn update_menu_state( + app_handle: tauri::AppHandle, + has_active_note: bool, + has_modified_files: Option, + has_conflicts: Option, +) -> Result<(), String> { menu::set_note_items_enabled(&app_handle, has_active_note); + if let Some(v) = has_modified_files { + menu::set_git_commit_items_enabled(&app_handle, v); + } + if let Some(v) = has_conflicts { + menu::set_git_conflict_items_enabled(&app_handle, v); + } Ok(()) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 40bc9df3..c73a85d6 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -5,51 +5,109 @@ use tauri::{ // Custom menu item IDs that emit events to the frontend. const APP_SETTINGS: &str = "app-settings"; +const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates"; + const FILE_NEW_NOTE: &str = "file-new-note"; +const FILE_NEW_TYPE: &str = "file-new-type"; const FILE_DAILY_NOTE: &str = "file-daily-note"; const FILE_QUICK_OPEN: &str = "file-quick-open"; const FILE_SAVE: &str = "file-save"; const FILE_CLOSE_TAB: &str = "file-close-tab"; + +const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; +const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor"; +const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff"; + 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_TOGGLE_PROPERTIES: &str = "view-toggle-properties"; +const VIEW_TOGGLE_AI_CHAT: &str = "view-toggle-ai-chat"; +const VIEW_TOGGLE_BACKLINKS: &str = "view-toggle-backlinks"; const VIEW_COMMAND_PALETTE: &str = "view-command-palette"; const VIEW_ZOOM_IN: &str = "view-zoom-in"; const VIEW_ZOOM_OUT: &str = "view-zoom-out"; const VIEW_ZOOM_RESET: &str = "view-zoom-reset"; -const NOTE_ARCHIVE: &str = "note-archive"; -const NOTE_TRASH: &str = "note-trash"; -const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; const VIEW_GO_BACK: &str = "view-go-back"; const VIEW_GO_FORWARD: &str = "view-go-forward"; -const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates"; + +const GO_ALL_NOTES: &str = "go-all-notes"; +const GO_FAVORITES: &str = "go-favorites"; +const GO_ARCHIVED: &str = "go-archived"; +const GO_TRASH: &str = "go-trash"; +const GO_CHANGES: &str = "go-changes"; + +const NOTE_ARCHIVE: &str = "note-archive"; +const NOTE_TRASH: &str = "note-trash"; + +const VAULT_OPEN: &str = "vault-open"; +const VAULT_REMOVE: &str = "vault-remove"; +const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started"; +const VAULT_NEW_THEME: &str = "vault-new-theme"; +const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes"; +const VAULT_COMMIT_PUSH: &str = "vault-commit-push"; +const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts"; +const VAULT_VIEW_CHANGES: &str = "vault-view-changes"; +const VAULT_INSTALL_MCP: &str = "vault-install-mcp"; const CUSTOM_IDS: &[&str] = &[ APP_SETTINGS, + APP_CHECK_FOR_UPDATES, FILE_NEW_NOTE, + FILE_NEW_TYPE, FILE_DAILY_NOTE, FILE_QUICK_OPEN, FILE_SAVE, FILE_CLOSE_TAB, - NOTE_ARCHIVE, - NOTE_TRASH, EDIT_FIND_IN_VAULT, + EDIT_TOGGLE_RAW_EDITOR, + EDIT_TOGGLE_DIFF, VIEW_EDITOR_ONLY, VIEW_EDITOR_LIST, VIEW_ALL, - VIEW_TOGGLE_INSPECTOR, + VIEW_TOGGLE_PROPERTIES, + VIEW_TOGGLE_AI_CHAT, + VIEW_TOGGLE_BACKLINKS, VIEW_COMMAND_PALETTE, VIEW_ZOOM_IN, VIEW_ZOOM_OUT, VIEW_ZOOM_RESET, VIEW_GO_BACK, VIEW_GO_FORWARD, - APP_CHECK_FOR_UPDATES, + GO_ALL_NOTES, + GO_FAVORITES, + GO_ARCHIVED, + GO_TRASH, + GO_CHANGES, + NOTE_ARCHIVE, + NOTE_TRASH, + VAULT_OPEN, + VAULT_REMOVE, + VAULT_RESTORE_GETTING_STARTED, + VAULT_NEW_THEME, + VAULT_RESTORE_DEFAULT_THEMES, + VAULT_COMMIT_PUSH, + VAULT_RESOLVE_CONFLICTS, + VAULT_VIEW_CHANGES, + VAULT_INSTALL_MCP, ]; /// IDs of menu items that should be disabled when no note tab is active. -const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH]; +const NOTE_DEPENDENT_IDS: &[&str] = &[ + FILE_SAVE, + FILE_CLOSE_TAB, + NOTE_ARCHIVE, + NOTE_TRASH, + EDIT_TOGGLE_RAW_EDITOR, + EDIT_TOGGLE_DIFF, + VIEW_TOGGLE_BACKLINKS, +]; + +/// IDs of menu items that depend on having uncommitted changes. +const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH]; + +/// IDs of menu items that depend on having merge conflicts. +const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS]; type MenuResult = Result, Box>; @@ -84,6 +142,9 @@ fn build_file_menu(app: &App) -> MenuResult { .id(FILE_NEW_NOTE) .accelerator("CmdOrCtrl+N") .build(app)?; + let new_type = MenuItemBuilder::new("New Type") + .id(FILE_NEW_TYPE) + .build(app)?; let daily_note = MenuItemBuilder::new("Open Today's Note") .id(FILE_DAILY_NOTE) .accelerator("CmdOrCtrl+J") @@ -100,25 +161,14 @@ fn build_file_menu(app: &App) -> MenuResult { .id(FILE_CLOSE_TAB) .accelerator("CmdOrCtrl+W") .build(app)?; - let archive_note = MenuItemBuilder::new("Archive Note") - .id(NOTE_ARCHIVE) - .accelerator("CmdOrCtrl+E") - .build(app)?; - let trash_note = MenuItemBuilder::new("Trash Note") - .id(NOTE_TRASH) - .accelerator("CmdOrCtrl+Backspace") - .build(app)?; Ok(SubmenuBuilder::new(app, "File") .item(&new_note) + .item(&new_type) .item(&daily_note) .item(&quick_open) .separator() .item(&save) - .separator() - .item(&archive_note) - .item(&trash_note) - .separator() .item(&close_tab) .build()?) } @@ -128,6 +178,13 @@ fn build_edit_menu(app: &App) -> MenuResult { .id(EDIT_FIND_IN_VAULT) .accelerator("CmdOrCtrl+Shift+F") .build(app)?; + let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor") + .id(EDIT_TOGGLE_RAW_EDITOR) + .accelerator("CmdOrCtrl+\\") + .build(app)?; + let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode") + .id(EDIT_TOGGLE_DIFF) + .build(app)?; Ok(SubmenuBuilder::new(app, "Edit") .undo() @@ -140,6 +197,8 @@ fn build_edit_menu(app: &App) -> MenuResult { .select_all() .separator() .item(&find_in_vault) + .item(&toggle_raw_editor) + .item(&toggle_diff) .build()?) } @@ -156,8 +215,15 @@ fn build_view_menu(app: &App) -> MenuResult { .id(VIEW_ALL) .accelerator("CmdOrCtrl+3") .build(app)?; - let toggle_inspector = MenuItemBuilder::new("Toggle Inspector") - .id(VIEW_TOGGLE_INSPECTOR) + let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel") + .id(VIEW_TOGGLE_PROPERTIES) + .build(app)?; + let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Chat") + .id(VIEW_TOGGLE_AI_CHAT) + .accelerator("CmdOrCtrl+I") + .build(app)?; + let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks") + .id(VIEW_TOGGLE_BACKLINKS) .build(app)?; let command_palette = MenuItemBuilder::new("Command Palette") .id(VIEW_COMMAND_PALETTE) @@ -189,19 +255,109 @@ fn build_view_menu(app: &App) -> MenuResult { .item(&editor_list) .item(&all_panels) .separator() - .item(&toggle_inspector) - .separator() - .item(&go_back) - .item(&go_forward) + .item(&toggle_properties) + .item(&toggle_ai_chat) + .item(&toggle_backlinks) .separator() .item(&zoom_in) .item(&zoom_out) .item(&zoom_reset) .separator() + .item(&go_back) + .item(&go_forward) + .separator() .item(&command_palette) .build()?) } +fn build_go_menu(app: &App) -> MenuResult { + let all_notes = MenuItemBuilder::new("All Notes") + .id(GO_ALL_NOTES) + .build(app)?; + let favorites = MenuItemBuilder::new("Favorites") + .id(GO_FAVORITES) + .build(app)?; + let archived = MenuItemBuilder::new("Archived") + .id(GO_ARCHIVED) + .build(app)?; + let trash = MenuItemBuilder::new("Trash") + .id(GO_TRASH) + .build(app)?; + let changes = MenuItemBuilder::new("Changes") + .id(GO_CHANGES) + .build(app)?; + + Ok(SubmenuBuilder::new(app, "Go") + .item(&all_notes) + .item(&favorites) + .item(&archived) + .item(&trash) + .item(&changes) + .build()?) +} + +fn build_note_menu(app: &App) -> MenuResult { + let archive_note = MenuItemBuilder::new("Archive Note") + .id(NOTE_ARCHIVE) + .accelerator("CmdOrCtrl+E") + .build(app)?; + let trash_note = MenuItemBuilder::new("Trash Note") + .id(NOTE_TRASH) + .accelerator("CmdOrCtrl+Backspace") + .build(app)?; + + Ok(SubmenuBuilder::new(app, "Note") + .item(&archive_note) + .item(&trash_note) + .build()?) +} + +fn build_vault_menu(app: &App) -> MenuResult { + let open_vault = MenuItemBuilder::new("Open Vault…") + .id(VAULT_OPEN) + .build(app)?; + let remove_vault = MenuItemBuilder::new("Remove Vault from List") + .id(VAULT_REMOVE) + .build(app)?; + let restore_getting_started = MenuItemBuilder::new("Restore Getting Started") + .id(VAULT_RESTORE_GETTING_STARTED) + .build(app)?; + let new_theme = MenuItemBuilder::new("New Theme") + .id(VAULT_NEW_THEME) + .build(app)?; + let restore_default_themes = MenuItemBuilder::new("Restore Default Themes") + .id(VAULT_RESTORE_DEFAULT_THEMES) + .build(app)?; + let commit_push = MenuItemBuilder::new("Commit & Push") + .id(VAULT_COMMIT_PUSH) + .build(app)?; + let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts") + .id(VAULT_RESOLVE_CONFLICTS) + .enabled(false) + .build(app)?; + let view_changes = MenuItemBuilder::new("View Pending Changes") + .id(VAULT_VIEW_CHANGES) + .build(app)?; + let install_mcp = MenuItemBuilder::new("Install MCP Server") + .id(VAULT_INSTALL_MCP) + .build(app)?; + + Ok(SubmenuBuilder::new(app, "Vault") + .item(&open_vault) + .item(&remove_vault) + .item(&restore_getting_started) + .separator() + .item(&new_theme) + .item(&restore_default_themes) + .separator() + .item(&commit_push) + .item(&resolve_conflicts) + .item(&view_changes) + .separator() + .item(&install_mcp) + .build()?) +} + fn build_window_menu(app: &App) -> MenuResult { Ok(SubmenuBuilder::new(app, "Window") .minimize() @@ -216,6 +372,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box> { let file_menu = build_file_menu(app)?; let edit_menu = build_edit_menu(app)?; let view_menu = build_view_menu(app)?; + let go_menu = build_go_menu(app)?; + let note_menu = build_note_menu(app)?; + let vault_menu = build_vault_menu(app)?; let window_menu = build_window_menu(app)?; let menu = MenuBuilder::new(app) @@ -223,6 +382,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box> { .item(&file_menu) .item(&edit_menu) .item(&view_menu) + .item(&go_menu) + .item(¬e_menu) + .item(&vault_menu) .item(&window_menu) .build()?; @@ -238,45 +400,78 @@ pub fn setup_menu(app: &App) -> Result<(), Box> { 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) { +fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) { let Some(menu) = app_handle.menu() else { return; }; - for id in NOTE_DEPENDENT_IDS { + for id in ids { if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) { let _ = mi.set_enabled(enabled); } } } +/// Enable or disable menu items that depend on having an active note tab. +pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled); +} + +/// Enable or disable menu items that depend on having uncommitted changes. +pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled); +} + +/// Enable or disable menu items that depend on having merge conflicts. +pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled); +} + #[cfg(test)] mod tests { use super::*; #[test] - fn custom_ids_include_all_expected_items() { + fn custom_ids_include_all_constants() { let expected = [ - "app-settings", - "file-new-note", - "file-daily-note", - "file-quick-open", - "file-save", - "file-close-tab", - "note-archive", - "note-trash", - "edit-find-in-vault", - "view-editor-only", - "view-editor-list", - "view-all", - "view-toggle-inspector", - "view-command-palette", - "view-zoom-in", - "view-zoom-out", - "view-zoom-reset", - "view-go-back", - "view-go-forward", - "app-check-for-updates", + APP_SETTINGS, + APP_CHECK_FOR_UPDATES, + FILE_NEW_NOTE, + FILE_NEW_TYPE, + FILE_DAILY_NOTE, + FILE_QUICK_OPEN, + FILE_SAVE, + FILE_CLOSE_TAB, + EDIT_FIND_IN_VAULT, + EDIT_TOGGLE_RAW_EDITOR, + EDIT_TOGGLE_DIFF, + VIEW_EDITOR_ONLY, + VIEW_EDITOR_LIST, + VIEW_ALL, + VIEW_TOGGLE_PROPERTIES, + VIEW_TOGGLE_AI_CHAT, + VIEW_TOGGLE_BACKLINKS, + VIEW_COMMAND_PALETTE, + VIEW_ZOOM_IN, + VIEW_ZOOM_OUT, + VIEW_ZOOM_RESET, + VIEW_GO_BACK, + VIEW_GO_FORWARD, + GO_ALL_NOTES, + GO_FAVORITES, + GO_ARCHIVED, + GO_TRASH, + GO_CHANGES, + NOTE_ARCHIVE, + NOTE_TRASH, + VAULT_OPEN, + VAULT_REMOVE, + VAULT_RESTORE_GETTING_STARTED, + VAULT_NEW_THEME, + VAULT_RESTORE_DEFAULT_THEMES, + VAULT_COMMIT_PUSH, + VAULT_RESOLVE_CONFLICTS, + VAULT_VIEW_CHANGES, + VAULT_INSTALL_MCP, ]; for id in &expected { assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}"); @@ -292,4 +487,28 @@ mod tests { ); } } + + #[test] + fn git_dependent_ids_are_subset_of_custom_ids() { + for id in GIT_COMMIT_DEPENDENT_IDS { + assert!( + CUSTOM_IDS.contains(id), + "git-commit-dependent ID {id} not in CUSTOM_IDS" + ); + } + for id in GIT_CONFLICT_DEPENDENT_IDS { + assert!( + CUSTOM_IDS.contains(id), + "git-conflict-dependent ID {id} not in CUSTOM_IDS" + ); + } + } + + #[test] + fn no_duplicate_custom_ids() { + let mut seen = std::collections::HashSet::new(); + for id in CUSTOM_IDS { + assert!(seen.insert(id), "duplicate custom ID: {id}"); + } + } } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 684e7710..9a0b7204 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -85,6 +85,16 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { ;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path) }, [config.onTrashNote, config.onRestoreNote]) + const { onSelect } = config + + const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes') => { + onSelect({ kind: 'filter', filter }) + }, [onSelect]) + + const viewChanges = useCallback(() => { + onSelect({ kind: 'filter', filter: 'changes' }) + }, [onSelect]) + useAppKeyboard({ onQuickOpen: config.onQuickOpen, onCommandPalette: config.onCommandPalette, @@ -110,6 +120,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { useMenuEvents({ onSetViewMode: config.onSetViewMode, onCreateNote: config.onCreateNote, + onCreateType: config.onCreateType, onOpenDailyNote: config.onOpenDailyNote, onQuickOpen: config.onQuickOpen, onSave: config.onSave, @@ -122,12 +133,27 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onArchiveNote: toggleArchive, onTrashNote: toggleTrash, onSearch: config.onSearch, + onToggleRawEditor: config.onToggleRawEditor, + onToggleDiff: config.onToggleDiff, + onToggleAIChat: config.onToggleAIChat, onGoBack: config.onGoBack, onGoForward: config.onGoForward, onCheckForUpdates: config.onCheckForUpdates, + onSelectFilter: selectFilter, + onOpenVault: config.onOpenVault, + onRemoveActiveVault: config.onRemoveActiveVault, + onRestoreGettingStarted: config.onRestoreGettingStarted, + onCreateTheme: config.onCreateTheme, + onRestoreDefaultThemes: config.onRestoreDefaultThemes, + onCommitPush: config.onCommitPush, + onResolveConflicts: config.onResolveConflicts, + onViewChanges: viewChanges, + onInstallMcp: config.onInstallMcp, activeTabPathRef: config.activeTabPathRef, handleCloseTabRef: config.handleCloseTabRef, activeTabPath: config.activeTabPath, + modifiedCount: config.modifiedCount, + conflictCount: config.conflictCount, }) const commands = useCommandRegistry({ @@ -150,6 +176,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onToggleInspector: config.onToggleInspector, onToggleDiff: config.onToggleDiff, onToggleRawEditor: config.onToggleRawEditor, + onToggleAIChat: config.onToggleAIChat, + onOpenVault: config.onOpenVault, activeNoteModified: config.activeNoteModified, onZoomIn: config.onZoomIn, onZoomOut: config.onZoomOut, @@ -167,10 +195,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onSwitchTheme: config.onSwitchTheme, onCreateTheme: config.onCreateTheme, onOpenTheme: config.onOpenTheme, - onOpenVault: config.onOpenVault, - onCreateType: config.onCreateType, - onToggleAIChat: config.onToggleAIChat, onCheckForUpdates: config.onCheckForUpdates, + onCreateType: config.onCreateType, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, onRestoreDefaultThemes: config.onRestoreDefaultThemes, diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index 8cf81a4b..62ce2aa6 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -5,6 +5,7 @@ function makeHandlers(): MenuEventHandlers { return { onSetViewMode: vi.fn(), onCreateNote: vi.fn(), + onCreateType: vi.fn(), onOpenDailyNote: vi.fn(), onQuickOpen: vi.fn(), onSave: vi.fn(), @@ -17,9 +18,22 @@ function makeHandlers(): MenuEventHandlers { onArchiveNote: vi.fn(), onTrashNote: vi.fn(), onSearch: vi.fn(), + onToggleRawEditor: vi.fn(), + onToggleDiff: vi.fn(), + onToggleAIChat: vi.fn(), onGoBack: vi.fn(), onGoForward: vi.fn(), onCheckForUpdates: vi.fn(), + onSelectFilter: vi.fn(), + onOpenVault: vi.fn(), + onRemoveActiveVault: vi.fn(), + onRestoreGettingStarted: vi.fn(), + onCreateTheme: vi.fn(), + onRestoreDefaultThemes: vi.fn(), + onCommitPush: vi.fn(), + onResolveConflicts: vi.fn(), + onViewChanges: vi.fn(), + onInstallMcp: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>, activeTabPath: '/vault/test.md', @@ -27,6 +41,7 @@ function makeHandlers(): MenuEventHandlers { } describe('dispatchMenuEvent', () => { + // View mode events it('view-editor-only sets editor-only mode', () => { const h = makeHandlers() dispatchMenuEvent('view-editor-only', h) @@ -45,6 +60,7 @@ describe('dispatchMenuEvent', () => { expect(h.onSetViewMode).toHaveBeenCalledWith('all') }) + // Simple handler events it('file-new-note triggers create note', () => { const h = makeHandlers() dispatchMenuEvent('file-new-note', h) @@ -88,9 +104,9 @@ describe('dispatchMenuEvent', () => { expect(h.onOpenSettings).toHaveBeenCalled() }) - it('view-toggle-inspector triggers toggle inspector', () => { + it('view-toggle-properties triggers toggle inspector', () => { const h = makeHandlers() - dispatchMenuEvent('view-toggle-inspector', h) + dispatchMenuEvent('view-toggle-properties', h) expect(h.onToggleInspector).toHaveBeenCalled() }) @@ -118,6 +134,13 @@ describe('dispatchMenuEvent', () => { expect(h.onZoomReset).toHaveBeenCalled() }) + it('edit-find-in-vault triggers search', () => { + const h = makeHandlers() + dispatchMenuEvent('edit-find-in-vault', h) + expect(h.onSearch).toHaveBeenCalled() + }) + + // Active tab-dependent events it('note-archive triggers archive on active tab', () => { const h = makeHandlers() dispatchMenuEvent('note-archive', h) @@ -144,12 +167,7 @@ describe('dispatchMenuEvent', () => { expect(h.onTrashNote).not.toHaveBeenCalled() }) - it('edit-find-in-vault triggers search', () => { - const h = makeHandlers() - dispatchMenuEvent('edit-find-in-vault', h) - expect(h.onSearch).toHaveBeenCalled() - }) - + // Optional handler events it('view-go-back triggers go back', () => { const h = makeHandlers() dispatchMenuEvent('view-go-back', h) @@ -168,6 +186,126 @@ describe('dispatchMenuEvent', () => { expect(h.onCheckForUpdates).toHaveBeenCalled() }) + // New File menu items + it('file-new-type triggers create type', () => { + const h = makeHandlers() + dispatchMenuEvent('file-new-type', h) + expect(h.onCreateType).toHaveBeenCalled() + }) + + // New Edit menu items + it('edit-toggle-raw-editor triggers toggle raw editor', () => { + const h = makeHandlers() + dispatchMenuEvent('edit-toggle-raw-editor', h) + expect(h.onToggleRawEditor).toHaveBeenCalled() + }) + + it('edit-toggle-diff triggers toggle diff', () => { + const h = makeHandlers() + dispatchMenuEvent('edit-toggle-diff', h) + expect(h.onToggleDiff).toHaveBeenCalled() + }) + + // New View menu items + it('view-toggle-ai-chat triggers toggle AI chat', () => { + const h = makeHandlers() + dispatchMenuEvent('view-toggle-ai-chat', h) + expect(h.onToggleAIChat).toHaveBeenCalled() + }) + + it('view-toggle-backlinks triggers toggle inspector', () => { + const h = makeHandlers() + dispatchMenuEvent('view-toggle-backlinks', h) + expect(h.onToggleInspector).toHaveBeenCalled() + }) + + // Go menu events + it('go-all-notes selects all filter', () => { + const h = makeHandlers() + dispatchMenuEvent('go-all-notes', h) + expect(h.onSelectFilter).toHaveBeenCalledWith('all') + }) + + it('go-favorites selects favorites filter', () => { + const h = makeHandlers() + dispatchMenuEvent('go-favorites', h) + expect(h.onSelectFilter).toHaveBeenCalledWith('favorites') + }) + + it('go-archived selects archived filter', () => { + const h = makeHandlers() + dispatchMenuEvent('go-archived', h) + expect(h.onSelectFilter).toHaveBeenCalledWith('archived') + }) + + it('go-trash selects trash filter', () => { + const h = makeHandlers() + dispatchMenuEvent('go-trash', h) + expect(h.onSelectFilter).toHaveBeenCalledWith('trash') + }) + + it('go-changes selects changes filter', () => { + const h = makeHandlers() + dispatchMenuEvent('go-changes', h) + expect(h.onSelectFilter).toHaveBeenCalledWith('changes') + }) + + // Vault menu events + it('vault-open triggers open vault', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-open', h) + expect(h.onOpenVault).toHaveBeenCalled() + }) + + it('vault-remove triggers remove active vault', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-remove', h) + expect(h.onRemoveActiveVault).toHaveBeenCalled() + }) + + it('vault-restore-getting-started triggers restore', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-restore-getting-started', h) + expect(h.onRestoreGettingStarted).toHaveBeenCalled() + }) + + it('vault-new-theme triggers create theme', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-new-theme', h) + expect(h.onCreateTheme).toHaveBeenCalled() + }) + + it('vault-restore-default-themes triggers restore default themes', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-restore-default-themes', h) + expect(h.onRestoreDefaultThemes).toHaveBeenCalled() + }) + + it('vault-commit-push triggers commit push', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-commit-push', h) + expect(h.onCommitPush).toHaveBeenCalled() + }) + + it('vault-resolve-conflicts triggers resolve conflicts', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-resolve-conflicts', h) + expect(h.onResolveConflicts).toHaveBeenCalled() + }) + + it('vault-view-changes triggers view changes', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-view-changes', h) + expect(h.onViewChanges).toHaveBeenCalled() + }) + + it('vault-install-mcp triggers install MCP', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-install-mcp', h) + expect(h.onInstallMcp).toHaveBeenCalled() + }) + + // Edge cases it('unknown event ID does nothing', () => { const h = makeHandlers() dispatchMenuEvent('unknown-event', h) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index bb898d44..252710b5 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -1,10 +1,12 @@ import { useEffect, useRef } from 'react' import { isTauri } from '../mock-tauri' +import type { SidebarFilter } from '../types' import type { ViewMode } from './useViewMode' export interface MenuEventHandlers { onSetViewMode: (mode: ViewMode) => void onCreateNote: () => void + onCreateType?: () => void onOpenDailyNote: () => void onQuickOpen: () => void onSave: () => void @@ -17,12 +19,27 @@ export interface MenuEventHandlers { onArchiveNote: (path: string) => void onTrashNote: (path: string) => void onSearch: () => void + onToggleRawEditor?: () => void + onToggleDiff?: () => void + onToggleAIChat?: () => void onGoBack?: () => void onGoForward?: () => void onCheckForUpdates?: () => void + onSelectFilter?: (filter: SidebarFilter) => void + onOpenVault?: () => void + onRemoveActiveVault?: () => void + onRestoreGettingStarted?: () => void + onCreateTheme?: () => void + onRestoreDefaultThemes?: () => void + onCommitPush?: () => void + onResolveConflicts?: () => void + onViewChanges?: () => void + onInstallMcp?: () => void activeTabPathRef: React.MutableRefObject handleCloseTabRef: React.MutableRefObject<(path: string) => void> activeTabPath: string | null + modifiedCount?: number + conflictCount?: number } const VIEW_MODE_MAP: Record = { @@ -39,7 +56,7 @@ const SIMPLE_EVENT_MAP: Record = { 'file-quick-open': 'onQuickOpen', 'file-save': 'onSave', 'app-settings': 'onOpenSettings', - 'view-toggle-inspector': 'onToggleInspector', + 'view-toggle-properties': 'onToggleInspector', 'view-command-palette': 'onCommandPalette', 'view-zoom-in': 'onZoomIn', 'view-zoom-out': 'onZoomOut', @@ -47,6 +64,41 @@ const SIMPLE_EVENT_MAP: Record = { 'edit-find-in-vault': 'onSearch', } +const FILTER_MAP: Record = { + 'go-all-notes': 'all', + 'go-favorites': 'favorites', + 'go-archived': 'archived', + 'go-trash': 'trash', + 'go-changes': 'changes', +} + +type OptionalHandler = + | 'onGoBack' | 'onGoForward' | 'onCheckForUpdates' + | 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat' + | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' + | 'onCreateTheme' | 'onRestoreDefaultThemes' + | 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' + +const OPTIONAL_EVENT_MAP: Record = { + 'view-go-back': 'onGoBack', + 'view-go-forward': 'onGoForward', + 'app-check-for-updates': 'onCheckForUpdates', + 'file-new-type': 'onCreateType', + 'edit-toggle-raw-editor': 'onToggleRawEditor', + 'edit-toggle-diff': 'onToggleDiff', + 'view-toggle-ai-chat': 'onToggleAIChat', + 'view-toggle-backlinks': 'onToggleInspector', + 'vault-open': 'onOpenVault', + 'vault-remove': 'onRemoveActiveVault', + 'vault-restore-getting-started': 'onRestoreGettingStarted', + 'vault-new-theme': 'onCreateTheme', + 'vault-restore-default-themes': 'onRestoreDefaultThemes', + 'vault-commit-push': 'onCommitPush', + 'vault-resolve-conflicts': 'onResolveConflicts', + 'vault-view-changes': 'onViewChanges', + 'vault-install-mcp': 'onInstallMcp', +} + function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean { const path = h.activeTabPathRef.current if (!path) return id === 'note-archive' || id === 'note-trash' || id === 'file-close-tab' @@ -57,9 +109,14 @@ function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean { } function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean { - if (id === 'view-go-back') { h.onGoBack?.(); return true } - if (id === 'view-go-forward') { h.onGoForward?.(); return true } - if (id === 'app-check-for-updates') { h.onCheckForUpdates?.(); return true } + const handler = OPTIONAL_EVENT_MAP[id] + if (handler) { h[handler]?.(); return true } + return false +} + +function dispatchFilterEvent(id: string, h: MenuEventHandlers): boolean { + const filter = FILTER_MAP[id] + if (filter) { h.onSelectFilter?.(filter); return true } return false } @@ -72,6 +129,7 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void { if (simple) { h[simple](); return } if (dispatchActiveTabEvent(id, h)) return + if (dispatchFilterEvent(id, h)) return dispatchOptionalEvent(id, h) } @@ -95,11 +153,15 @@ export function useMenuEvents(handlers: MenuEventHandlers) { return () => cleanup?.() }, []) - // Sync menu item enabled state when active tab changes + // Sync menu item enabled state when active tab or git state changes useEffect(() => { if (!isTauri()) return import('@tauri-apps/api/core').then(({ invoke }) => { - invoke('update_menu_state', { hasActiveNote: handlers.activeTabPath !== null }) + invoke('update_menu_state', { + hasActiveNote: handlers.activeTabPath !== null, + hasModifiedFiles: handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined, + hasConflicts: handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined, + }) }).catch(() => {}) - }, [handlers.activeTabPath]) + }, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount]) } diff --git a/src/types.ts b/src/types.ts index 89cba39d..a511a185 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,8 +139,10 @@ export interface VaultSettings { theme: string | null } +export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' + export type SidebarSelection = - | { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' } + | { kind: 'filter'; filter: SidebarFilter } | { kind: 'sectionGroup'; type: string } | { kind: 'entity'; entry: VaultEntry } | { kind: 'topic'; entry: VaultEntry }