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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-05 14:13:54 +01:00
parent 0eedf0c83a
commit a966b2cc65
6 changed files with 530 additions and 72 deletions

View File

@@ -303,8 +303,19 @@ fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
}
#[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<bool>,
has_conflicts: Option<bool>,
) -> 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(())
}

View File

@@ -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<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&go_menu)
.item(&note_menu)
.item(&vault_menu)
.item(&window_menu)
.build()?;
@@ -238,45 +400,78 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
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}");
}
}
}

View File

@@ -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,

View File

@@ -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<string | null>,
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)

View File

@@ -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<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
modifiedCount?: number
conflictCount?: number
}
const VIEW_MODE_MAP: Record<string, ViewMode> = {
@@ -39,7 +56,7 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'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<string, SimpleHandler> = {
'edit-find-in-vault': 'onSearch',
}
const FILTER_MAP: Record<string, SidebarFilter> = {
'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<string, OptionalHandler> = {
'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])
}

View File

@@ -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 }