Skip to content

Commit b38c552

Browse files
committed
Overhaul native menus on macOS and Linux
- Build all menus from scratch, no Menu::default() patching - Strip system-injected Edit junk (Writing Tools, AutoFill, Dictation, Emoji & Symbols) via objc2 cleanup pass - Register Help menu via NSApplication.setHelpMenu for search - Route ⌘Q, ⌘H, ⌥⌘H through PredefinedMenuItems (native-only) - Add all file operations to File menu (Copy, Move, Delete, etc.) - Dedicated Tab submenu, shared Sort by submenu - Rebuild viewer menu from scratch (minimal, no file ops) - Align Linux menus: same structure, GTK mnemonics, no F-key accelerators (GTK intercepts them) - Unify dispatch: single "execute-command" event replaces ~15 individual Tauri listeners - Focus guard on all file-scoped commands (skip when main window not focused) - Context-aware menus: file ops gray out when Settings/viewer has focus - Extend accelerator sync to all ~28 menu commands (was only 2) - Add app.licenseKey command, migrate MCP emitters to unified path - Update CLAUDE.md files for commands, shortcuts, and architecture
1 parent 22e2ea7 commit b38c552

22 files changed

Lines changed: 2287 additions & 1253 deletions

File tree

apps/desktop/src-tauri/Cargo.lock

Lines changed: 87 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,14 @@ core-services = "1.0.0"
114114
icns = "0.3.1"
115115
plist = "1.8.0"
116116
urlencoding = "2.1.3"
117-
objc2 = { version = "0.6", features = ["std"] }
117+
objc2 = { version = "0.6", features = ["std", "exception"] }
118118
objc2-foundation = { version = "0.3", features = [
119119
"NSURL", "NSString", "NSDictionary", "NSDate", "NSArray", "NSValue", "NSError",
120120
"NSFileManager", "NSNotification",
121121
] }
122122
objc2-app-kit = { version = "0.3", features = [
123123
"NSDragging", "NSDraggingItem", "NSImage", "NSColor", "NSColorSpace",
124+
"NSApplication", "NSMenu", "NSMenuItem", "NSRunningApplication",
124125
] }
125126
block2 = "0.6"
126127
security-framework = "3.2"

apps/desktop/src-tauri/src/commands/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ immediately to business-logic modules. No significant logic lives here.
1717
| `icons.rs` | File icons | `get_icons`, `refresh_directory_icons`, cache clear |
1818
| `rename.rs` | Rename / trash | `move_to_trash` (delegates to `write_operations::trash::move_to_trash_sync`), `check_rename_permission`, `check_rename_validity`, `rename_file` |
1919
| `file_viewer.rs` | File viewer | Session lifecycle, line search, word wrap, menu state |
20-
| `ui.rs` | UI / menu | Context menu, Finder reveal, clipboard, Quick Look, Get Info, view mode |
20+
| `ui.rs` | UI / menu | Context menu, Finder reveal, clipboard, Quick Look, Get Info, view mode, `set_menu_context` (enables/disables file-scoped menu items based on window focus) |
2121
| `settings.rs` | Settings | Port availability check, watcher debounce setting, menu accelerator updates |
2222
| `licensing.rs` | Licensing | Status query, activation, expiry, reminder, key validation |
2323
| `indexing.rs` | Drive index | `start_drive_index`, `stop_drive_index`, `get_index_status`, `get_dir_stats`, `get_dir_stats_batch`, `prioritize_dir`, `cancel_nav_priority`, `clear_drive_index`, `set_indexing_enabled`. Uses `State<IndexManagerState>`. |

apps/desktop/src-tauri/src/commands/settings.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use tauri::{AppHandle, Manager};
66

77
use crate::file_system::update_debounce_ms;
88
use crate::ignore_poison::IgnorePoison;
9-
use crate::menu::{MenuState, frontend_shortcut_to_accelerator, update_view_mode_accelerator};
9+
use crate::menu::{
10+
MenuState, command_id_to_menu_id, frontend_shortcut_to_accelerator, update_menu_item_accelerator,
11+
update_view_mode_accelerator,
12+
};
1013
#[cfg(target_os = "macos")]
1114
use crate::network::mdns_discovery::update_resolve_timeout;
1215

@@ -53,7 +56,6 @@ pub fn update_service_resolve_timeout(_timeout_ms: u64) {
5356

5457
/// Update menu accelerator for a command.
5558
/// Called from frontend when keyboard shortcuts are changed.
56-
/// Currently supports: view.fullMode, view.briefMode
5759
#[tauri::command]
5860
pub fn update_menu_accelerator(app: AppHandle, command_id: &str, shortcut: &str) -> Result<(), String> {
5961
let menu_state = app.state::<MenuState<tauri::Wry>>();
@@ -62,8 +64,8 @@ pub fn update_menu_accelerator(app: AppHandle, command_id: &str, shortcut: &str)
6264
let accelerator = frontend_shortcut_to_accelerator(shortcut);
6365

6466
match command_id {
67+
// View mode CheckMenuItems need special handling to preserve checked state
6568
"view.fullMode" => {
66-
// Get current checked state before updating
6769
let is_checked = menu_state
6870
.view_mode_full
6971
.lock_ignore_poison()
@@ -74,12 +76,10 @@ pub fn update_menu_accelerator(app: AppHandle, command_id: &str, shortcut: &str)
7476
let new_item = update_view_mode_accelerator(&app, &menu_state, true, accelerator.as_deref(), is_checked)
7577
.map_err(|e| format!("Failed to update Full view accelerator: {e}"))?;
7678

77-
// Update the reference in MenuState
7879
*menu_state.view_mode_full.lock_ignore_poison() = Some(new_item);
7980
Ok(())
8081
}
8182
"view.briefMode" => {
82-
// Get current checked state before updating
8383
let is_checked = menu_state
8484
.view_mode_brief
8585
.lock_ignore_poison()
@@ -90,13 +90,16 @@ pub fn update_menu_accelerator(app: AppHandle, command_id: &str, shortcut: &str)
9090
let new_item = update_view_mode_accelerator(&app, &menu_state, false, accelerator.as_deref(), is_checked)
9191
.map_err(|e| format!("Failed to update Brief view accelerator: {e}"))?;
9292

93-
// Update the reference in MenuState
9493
*menu_state.view_mode_brief.lock_ignore_poison() = Some(new_item);
9594
Ok(())
9695
}
96+
// All other commands: use the generic HashMap-based update
9797
_ => {
98+
if let Some(menu_id) = command_id_to_menu_id(command_id) {
99+
update_menu_item_accelerator(&app, &menu_state, menu_id, accelerator.as_deref())
100+
.map_err(|e| format!("Failed to update {command_id} accelerator: {e}"))?;
101+
}
98102
// Silently succeed for commands that don't have menu items
99-
// This allows the frontend to call this for all shortcuts without errors
100103
Ok(())
101104
}
102105
}

apps/desktop/src-tauri/src/commands/ui.rs

Lines changed: 27 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
use crate::ignore_poison::IgnorePoison;
2-
use crate::menu::{MenuState, build_context_menu, build_tab_context_menu};
2+
use crate::menu::{CommandScope, MenuState, build_context_menu, build_tab_context_menu, menu_id_to_command};
33
#[cfg(any(target_os = "macos", target_os = "linux"))]
44
use std::process::Command;
55
use tauri::menu::ContextMenu;
66
use tauri::{AppHandle, Emitter, Manager, Runtime, Window};
77
use tauri_plugin_clipboard_manager::ClipboardExt;
8-
use tauri_plugin_opener::OpenerExt;
98

109
#[tauri::command]
1110
pub fn update_menu_context<R: Runtime>(app: AppHandle<R>, path: String, filename: String) {
@@ -230,49 +229,34 @@ pub fn update_pin_tab_menu<R: Runtime>(app: AppHandle<R>, is_pinned: bool) -> Re
230229
item.set_text(label).map_err(|e| e.to_string())
231230
}
232231

233-
/// Executes a menu action for the current context.
234-
pub fn execute_menu_action<R: Runtime>(app: &AppHandle<R>, id: &str) {
235-
let state = app.state::<MenuState<R>>();
236-
let context = state.context.lock_ignore_poison().clone();
232+
/// Enables or disables file-scoped menu items based on the current context.
233+
/// - `"explorer"`: all menu items enabled (main file explorer has focus)
234+
/// - `"other"`: file-scoped items disabled (Settings, file viewer, or other window has focus)
235+
#[tauri::command]
236+
pub fn set_menu_context<R: Runtime>(app: AppHandle<R>, context: String) -> Result<(), String> {
237+
let enabled = context == "explorer";
238+
let menu_state = app.state::<MenuState<R>>();
239+
let items = menu_state.items.lock_ignore_poison();
237240

238-
if context.path.is_empty() {
239-
return;
241+
for (id, entry) in items.iter() {
242+
if let Some((_, CommandScope::FileScoped)) = menu_id_to_command(id) {
243+
let _ = entry.item.set_enabled(enabled);
244+
}
240245
}
241246

242-
match id {
243-
crate::menu::OPEN_ID => {
244-
let _ = app.opener().open_path(&context.path, None::<&str>);
245-
}
246-
crate::menu::EDIT_ID => {
247-
#[cfg(any(target_os = "macos", target_os = "linux"))]
248-
{
249-
let _ = open_in_editor(context.path);
250-
}
251-
}
252-
crate::menu::SHOW_IN_FINDER_ID => {
253-
#[cfg(any(target_os = "macos", target_os = "linux"))]
254-
{
255-
let _ = show_in_finder(context.path);
256-
}
257-
}
258-
crate::menu::COPY_PATH_ID => {
259-
let _ = app.clipboard().write_text(context.path);
260-
}
261-
crate::menu::COPY_FILENAME_ID => {
262-
let _ = app.clipboard().write_text(context.filename);
263-
}
264-
crate::menu::QUICK_LOOK_ID => {
265-
#[cfg(target_os = "macos")]
266-
{
267-
let _ = quick_look(context.path);
268-
}
269-
}
270-
crate::menu::GET_INFO_ID => {
271-
#[cfg(target_os = "macos")]
272-
{
273-
let _ = get_info(context.path);
274-
}
275-
}
276-
_ => {}
247+
// Items stored in separate MenuState fields (not in the HashMap) also need toggling
248+
if let Some(ref item) = *menu_state.pin_tab.lock_ignore_poison() {
249+
let _ = item.set_enabled(enabled);
250+
}
251+
if let Some(ref item) = *menu_state.show_hidden_files.lock_ignore_poison() {
252+
let _ = item.set_enabled(enabled);
277253
}
254+
if let Some(ref item) = *menu_state.view_mode_full.lock_ignore_poison() {
255+
let _ = item.set_enabled(enabled);
256+
}
257+
if let Some(ref item) = *menu_state.view_mode_brief.lock_ignore_poison() {
258+
let _ = item.set_enabled(enabled);
259+
}
260+
261+
Ok(())
278262
}

0 commit comments

Comments
 (0)