Skip to content

Commit 9c51fa9

Browse files
committed
Linux polish: shortcuts, menus, error msgs, E2E
- Volume choosers now use ⌥F1/⌥F2 on both platforms (was bare F1 + no right shortcut). Removed dead handleFunctionKey() from DualPaneExplorer. - Fix F2 rename on Linux: GTK menu accelerator was swallowing the key before it reached the webview. Removed F2 accelerator from Linux menu, letting centralized dispatch handle it. - 'Win' → 'Super' in formatKeyCombo() for standard Linux terminology. - Quick Look/Get Info: return no-op on Linux instead of error toast, unbind ⌘I (Ctrl+I) on Linux. - Appearance settings: DE detection via $XDG_CURRENT_DESKTOP (GNOME, KDE, XFCE) replacing broken xdg-open call. - GTK menu mnemonics on all labels (macOS silently strips & prefixes). - Replaced macOS-specific error strings (Finder, System Preferences, Full Disk Access) with platform-neutral text. - New inotify file-watching E2E test. - Update AGENTS.md to make sure we think in platform-native messaging on the UI
1 parent 0f2d1eb commit 9c51fa9

20 files changed

Lines changed: 314 additions & 118 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ There are two MCP servers available to you:
107107
processes as well, to avoid wasting the user's resources.
108108
- When shortcuts are available for a feature, always display the shortcut in a tooltip or somewhere, less prominent than
109109
the main UI.
110+
- **Platform-native, not generic.** The app should look and feel as if it was specifically made for the user's OS. Never
111+
generalize user-facing text, labels, or behavior to be "cross-platform" — instead, fork by OS. On macOS, say "Finder",
112+
"Trash", "System Settings". On Linux, say "file manager", "Trash" (FreeDesktop spec), and use DE-specific terminology
113+
where possible. Windows (later) gets its own native terms too. This applies to error messages, menu labels, tooltips,
114+
and any user-visible string. Use `isMacOS()` / `cfg(target_os)` to branch — a few extra lines of platform-specific
115+
text are always better than one watered-down generic string.
110116

111117
## Checklist for new features
112118

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ pub fn show_in_finder(path: String) -> Result<(), String> {
100100
Ok(())
101101
}
102102

103+
/// Show a file in the default file manager (open parent folder via xdg-open)
103104
#[tauri::command]
104105
#[cfg(target_os = "linux")]
105106
pub fn show_in_finder(path: String) -> Result<(), String> {
@@ -140,10 +141,10 @@ pub fn quick_look(path: String) -> Result<(), String> {
140141
#[tauri::command]
141142
#[cfg(not(target_os = "macos"))]
142143
pub fn quick_look(_path: String) -> Result<(), String> {
143-
Err("Quick Look is only available on macOS".to_string())
144+
Ok(())
144145
}
145146

146-
/// Open Get Info window in Finder (macOS only)
147+
/// Open the Get Info window for a file (macOS only, no-op on other platforms)
147148
#[tauri::command]
148149
#[cfg(target_os = "macos")]
149150
pub fn get_info(path: String) -> Result<(), String> {
@@ -167,7 +168,7 @@ pub fn get_info(path: String) -> Result<(), String> {
167168
#[tauri::command]
168169
#[cfg(not(target_os = "macos"))]
169170
pub fn get_info(_path: String) -> Result<(), String> {
170-
Err("Get Info is only available on macOS".to_string())
171+
Ok(())
171172
}
172173

173174
/// Open file in the system's default text editor (macOS only)

apps/desktop/src-tauri/src/menu.rs

Lines changed: 54 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ fn show_in_file_manager_label() -> &'static str {
159159

160160
#[cfg(not(target_os = "macos"))]
161161
fn show_in_file_manager_label() -> &'static str {
162-
"Show in file manager"
162+
"Show in &file manager"
163163
}
164164

165165
/// Builds the application menu with default macOS items plus a custom View and File submenu enhancements.
@@ -481,8 +481,8 @@ fn build_menu_linux<R: Runtime>(
481481
let menu = Menu::new(app)?;
482482

483483
// --- File menu ---
484-
let open_item = MenuItem::with_id(app, OPEN_ID, "Open", true, None::<&str>)?;
485-
let edit_item = MenuItem::with_id(app, EDIT_ID, "Edit", true, Some("F4"))?;
484+
let open_item = MenuItem::with_id(app, OPEN_ID, "&Open", true, None::<&str>)?;
485+
let edit_item = MenuItem::with_id(app, EDIT_ID, "&Edit", true, Some("F4"))?;
486486
let show_in_fm_item = MenuItem::with_id(
487487
app,
488488
SHOW_IN_FINDER_ID,
@@ -493,18 +493,18 @@ fn build_menu_linux<R: Runtime>(
493493
let copy_path_item = MenuItem::with_id(
494494
app,
495495
COPY_PATH_ID,
496-
"Copy path to clipboard",
496+
"Copy &path to clipboard",
497497
true,
498498
Some(copy_path_accelerator()),
499499
)?;
500-
let copy_filename_item = MenuItem::with_id(app, COPY_FILENAME_ID, "Copy filename", true, None::<&str>)?;
501-
let new_tab_item = MenuItem::with_id(app, NEW_TAB_ID, "New tab", true, Some("Cmd+T"))?;
502-
let pin_tab_item = MenuItem::with_id(app, PIN_TAB_MENU_ID, "Pin tab", true, None::<&str>)?;
503-
let close_tab_item = MenuItem::with_id(app, CLOSE_TAB_ID, "Close tab", true, Some("Cmd+W"))?;
500+
let copy_filename_item = MenuItem::with_id(app, COPY_FILENAME_ID, "Copy file&name", true, None::<&str>)?;
501+
let new_tab_item = MenuItem::with_id(app, NEW_TAB_ID, "New &tab", true, Some("Cmd+T"))?;
502+
let pin_tab_item = MenuItem::with_id(app, PIN_TAB_MENU_ID, "P&in tab", true, None::<&str>)?;
503+
let close_tab_item = MenuItem::with_id(app, CLOSE_TAB_ID, "&Close tab", true, Some("Cmd+W"))?;
504504

505505
let file_menu = Submenu::with_items(
506506
app,
507-
"File",
507+
"&File",
508508
true,
509509
&[
510510
&open_item,
@@ -521,27 +521,30 @@ fn build_menu_linux<R: Runtime>(
521521
menu.append(&file_menu)?;
522522

523523
// --- Edit menu ---
524-
let rename_item = MenuItem::with_id(app, RENAME_ID, "Rename", true, Some("F2"))?;
525-
let settings_item = MenuItem::with_id(app, SETTINGS_ID, "Settings...", true, Some("Cmd+,"))?;
524+
// No accelerator — F2 is handled by the webview's centralized shortcut dispatch.
525+
// GTK menu accelerators intercept keys before they reach the webview, and the
526+
// is_focused() check in on_menu_event fails on Linux, so F2 rename never fires.
527+
let rename_item = MenuItem::with_id(app, RENAME_ID, "Re&name", true, None::<&str>)?;
528+
let settings_item = MenuItem::with_id(app, SETTINGS_ID, "&Settings...", true, Some("Cmd+,"))?;
526529
let license_label = if has_existing_license {
527-
"See license details..."
530+
"See &license details..."
528531
} else {
529-
"Enter license key..."
532+
"Enter &license key..."
530533
};
531534
let license_item = MenuItem::with_id(app, ENTER_LICENSE_KEY_ID, license_label, true, None::<&str>)?;
532535

533536
let edit_menu = Submenu::with_items(
534537
app,
535-
"Edit",
538+
"&Edit",
536539
true,
537540
&[
538-
&PredefinedMenuItem::undo(app, None)?,
539-
&PredefinedMenuItem::redo(app, None)?,
541+
&PredefinedMenuItem::undo(app, Some("&Undo"))?,
542+
&PredefinedMenuItem::redo(app, Some("&Redo"))?,
540543
&PredefinedMenuItem::separator(app)?,
541-
&PredefinedMenuItem::cut(app, None)?,
542-
&PredefinedMenuItem::copy(app, None)?,
543-
&PredefinedMenuItem::paste(app, None)?,
544-
&PredefinedMenuItem::select_all(app, None)?,
544+
&PredefinedMenuItem::cut(app, Some("Cu&t"))?,
545+
&PredefinedMenuItem::copy(app, Some("&Copy"))?,
546+
&PredefinedMenuItem::paste(app, Some("&Paste"))?,
547+
&PredefinedMenuItem::select_all(app, Some("Select &all"))?,
545548
&PredefinedMenuItem::separator(app)?,
546549
&rename_item,
547550
&PredefinedMenuItem::separator(app)?,
@@ -555,39 +558,39 @@ fn build_menu_linux<R: Runtime>(
555558
let view_mode_full_item = CheckMenuItem::with_id(
556559
app,
557560
VIEW_MODE_FULL_ID,
558-
"Full view",
561+
"&Full view",
559562
true,
560563
view_mode == ViewMode::Full,
561564
Some("Cmd+1"),
562565
)?;
563566
let view_mode_brief_item = CheckMenuItem::with_id(
564567
app,
565568
VIEW_MODE_BRIEF_ID,
566-
"Brief view",
569+
"&Brief view",
567570
true,
568571
view_mode == ViewMode::Brief,
569572
Some("Cmd+2"),
570573
)?;
571574
let show_hidden_item = CheckMenuItem::with_id(
572575
app,
573576
SHOW_HIDDEN_FILES_ID,
574-
"Show hidden files",
577+
"Show &hidden files",
575578
true,
576579
show_hidden_files,
577580
Some("Cmd+Shift+."),
578581
)?;
579582

580583
// Sort by submenu
581-
let sort_by_name = MenuItem::with_id(app, SORT_BY_NAME_ID, "Name", true, None::<&str>)?;
582-
let sort_by_ext = MenuItem::with_id(app, SORT_BY_EXTENSION_ID, "Extension", true, None::<&str>)?;
583-
let sort_by_size = MenuItem::with_id(app, SORT_BY_SIZE_ID, "Size", true, None::<&str>)?;
584-
let sort_by_modified = MenuItem::with_id(app, SORT_BY_MODIFIED_ID, "Date modified", true, None::<&str>)?;
585-
let sort_by_created = MenuItem::with_id(app, SORT_BY_CREATED_ID, "Date created", true, None::<&str>)?;
586-
let sort_asc = MenuItem::with_id(app, SORT_ASCENDING_ID, "Ascending", true, None::<&str>)?;
587-
let sort_desc = MenuItem::with_id(app, SORT_DESCENDING_ID, "Descending", true, None::<&str>)?;
584+
let sort_by_name = MenuItem::with_id(app, SORT_BY_NAME_ID, "&Name", true, None::<&str>)?;
585+
let sort_by_ext = MenuItem::with_id(app, SORT_BY_EXTENSION_ID, "&Extension", true, None::<&str>)?;
586+
let sort_by_size = MenuItem::with_id(app, SORT_BY_SIZE_ID, "&Size", true, None::<&str>)?;
587+
let sort_by_modified = MenuItem::with_id(app, SORT_BY_MODIFIED_ID, "Date &modified", true, None::<&str>)?;
588+
let sort_by_created = MenuItem::with_id(app, SORT_BY_CREATED_ID, "Date &created", true, None::<&str>)?;
589+
let sort_asc = MenuItem::with_id(app, SORT_ASCENDING_ID, "&Ascending", true, None::<&str>)?;
590+
let sort_desc = MenuItem::with_id(app, SORT_DESCENDING_ID, "&Descending", true, None::<&str>)?;
588591
let sort_submenu = Submenu::with_items(
589592
app,
590-
"Sort by",
593+
"&Sort by",
591594
true,
592595
&[
593596
&sort_by_name,
@@ -601,14 +604,19 @@ fn build_menu_linux<R: Runtime>(
601604
],
602605
)?;
603606

604-
let command_palette_item =
605-
MenuItem::with_id(app, COMMAND_PALETTE_ID, "Command palette...", true, Some("Cmd+Shift+P"))?;
606-
let switch_pane_item = MenuItem::with_id(app, SWITCH_PANE_ID, "Switch pane", true, Some("Tab"))?;
607-
let swap_panes_item = MenuItem::with_id(app, SWAP_PANES_ID, "Swap panes", true, Some("Cmd+U"))?;
607+
let command_palette_item = MenuItem::with_id(
608+
app,
609+
COMMAND_PALETTE_ID,
610+
"&Command palette...",
611+
true,
612+
Some("Cmd+Shift+P"),
613+
)?;
614+
let switch_pane_item = MenuItem::with_id(app, SWITCH_PANE_ID, "S&witch pane", true, Some("Tab"))?;
615+
let swap_panes_item = MenuItem::with_id(app, SWAP_PANES_ID, "Swa&p panes", true, Some("Cmd+U"))?;
608616

609617
let view_submenu = Submenu::with_items(
610618
app,
611-
"View",
619+
"&View",
612620
true,
613621
&[
614622
&view_mode_full_item,
@@ -629,13 +637,13 @@ fn build_menu_linux<R: Runtime>(
629637
let view_brief_pos: usize = 1;
630638

631639
// --- Go menu ---
632-
let go_back_item = MenuItem::with_id(app, GO_BACK_ID, "Back", true, Some("Cmd+["))?;
633-
let go_forward_item = MenuItem::with_id(app, GO_FORWARD_ID, "Forward", true, Some("Cmd+]"))?;
634-
let go_parent_item = MenuItem::with_id(app, GO_PARENT_ID, "Parent folder", true, Some("Cmd+Up"))?;
640+
let go_back_item = MenuItem::with_id(app, GO_BACK_ID, "&Back", true, Some("Cmd+["))?;
641+
let go_forward_item = MenuItem::with_id(app, GO_FORWARD_ID, "&Forward", true, Some("Cmd+]"))?;
642+
let go_parent_item = MenuItem::with_id(app, GO_PARENT_ID, "&Parent folder", true, Some("Cmd+Up"))?;
635643

636644
let go_menu = Submenu::with_items(
637645
app,
638-
"Go",
646+
"&Go",
639647
true,
640648
&[
641649
&go_back_item,
@@ -647,8 +655,8 @@ fn build_menu_linux<R: Runtime>(
647655
menu.append(&go_menu)?;
648656

649657
// --- Help menu ---
650-
let about_item = MenuItem::with_id(app, ABOUT_ID, "About cmdr", true, None::<&str>)?;
651-
let help_menu = Submenu::with_items(app, "Help", true, &[&about_item])?;
658+
let about_item = MenuItem::with_id(app, ABOUT_ID, "&About cmdr", true, None::<&str>)?;
659+
let help_menu = Submenu::with_items(app, "&Help", true, &[&about_item])?;
652660
menu.append(&help_menu)?;
653661

654662
Ok(MenuItems {
@@ -747,8 +755,8 @@ pub fn build_viewer_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R
747755
{
748756
let menu = Menu::new(app)?;
749757

750-
let word_wrap_item = CheckMenuItem::with_id(app, VIEWER_WORD_WRAP_ID, "Word wrap", true, false, None::<&str>)?;
751-
let view_submenu = Submenu::with_items(app, "View", true, &[&word_wrap_item])?;
758+
let word_wrap_item = CheckMenuItem::with_id(app, VIEWER_WORD_WRAP_ID, "&Word wrap", true, false, None::<&str>)?;
759+
let view_submenu = Submenu::with_items(app, "&View", true, &[&word_wrap_item])?;
752760
menu.append(&view_submenu)?;
753761

754762
Ok(menu)
@@ -874,14 +882,14 @@ pub fn update_view_mode_accelerator<R: Runtime>(
874882
menu_state.view_mode_full.lock_ignore_poison(),
875883
menu_state.view_mode_full_position.lock_ignore_poison(),
876884
VIEW_MODE_FULL_ID,
877-
"Full view",
885+
"&Full view",
878886
)
879887
} else {
880888
(
881889
menu_state.view_mode_brief.lock_ignore_poison(),
882890
menu_state.view_mode_brief_position.lock_ignore_poison(),
883891
VIEW_MODE_BRIEF_ID,
884-
"Brief view",
892+
"&Brief view",
885893
)
886894
};
887895

apps/desktop/src-tauri/src/permissions_linux.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,27 @@ pub fn open_privacy_settings() -> Result<(), String> {
1717
// GNOME: gnome-control-center privacy
1818
// KDE: systemsettings (no direct privacy section URL)
1919
// Fallback: xdg-open is unlikely to have a privacy URI, so return an error.
20-
Err("Privacy settings are not applicable on Linux".to_string())
20+
Err("Privacy settings aren't needed on Linux — file access is governed by standard Unix permissions, not app sandboxing.".to_string())
2121
}
2222

2323
/// Opens the system appearance settings via the desktop environment.
24+
/// Detects the DE from `$XDG_CURRENT_DESKTOP` and launches the appropriate settings app.
2425
#[tauri::command]
2526
pub fn open_appearance_settings() -> Result<(), String> {
26-
std::process::Command::new("xdg-open")
27-
.arg("gnome-control-center")
27+
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().to_uppercase();
28+
29+
let (cmd, args): (&str, &[&str]) = if desktop.contains("GNOME") {
30+
("gnome-control-center", &["appearance"])
31+
} else if desktop.contains("KDE") {
32+
("systemsettings", &["kcm_lookandfeel"])
33+
} else if desktop.contains("XFCE") {
34+
("xfce4-appearance-settings", &[])
35+
} else {
36+
return Err("Appearance settings are not available for your desktop environment.".to_string());
37+
};
38+
39+
std::process::Command::new(cmd)
40+
.args(args)
2841
.spawn()
2942
.map_err(|e| format!("Failed to open appearance settings: {e}"))?;
3043
Ok(())

apps/desktop/src/lib/commands/command-registry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ export const commands: Command[] = [
7676
name: 'Open left volume chooser',
7777
scope: 'Main window',
7878
showInPalette: true,
79-
shortcuts: ['F1'],
79+
shortcuts: ['F1'],
8080
},
8181
{
8282
id: 'pane.rightVolumeChooser',
8383
name: 'Open right volume chooser',
8484
scope: 'Main window',
8585
showInPalette: true,
86-
shortcuts: [],
86+
shortcuts: ['⌥F2'],
8787
},
8888

8989
// ============================================================================
@@ -261,7 +261,7 @@ export const commands: Command[] = [
261261
name: isMacOS() ? 'Get info' : 'File properties',
262262
scope: 'Main window/File list',
263263
showInPalette: isMacOS(),
264-
shortcuts: ['⌘I'],
264+
shortcuts: isMacOS() ? ['⌘I'] : [],
265265
},
266266
{
267267
id: 'file.quickLook',

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -613,30 +613,13 @@
613613
return false
614614
}
615615
616-
/** Handles the F1 key (volume chooser toggle). Returns true if handled. */
617-
function handleFunctionKey(e: KeyboardEvent): boolean {
618-
if (e.key === 'F1') {
619-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
620-
getPaneRef('right')?.closeVolumeChooser()
621-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
622-
getPaneRef('left')?.toggleVolumeChooser()
623-
return true
624-
}
625-
return false
626-
}
627-
628616
function handleKeyDown(e: KeyboardEvent) {
629617
// ESC during loading = cancel and go back
630618
if (e.key === 'Escape' && handleEscapeDuringLoading()) {
631619
e.preventDefault()
632620
return
633621
}
634622
635-
if (handleFunctionKey(e)) {
636-
e.preventDefault()
637-
return
638-
}
639-
640623
// Route to volume chooser if one is open
641624
if (routeToVolumeChooser(e)) {
642625
return

apps/desktop/src/lib/file-explorer/pane/PermissionDeniedPane.svelte

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { DotLottieSvelte } from '@lottiefiles/dotlottie-svelte'
33
import { openPrivacySettings } from '$lib/tauri-commands'
4+
import { isMacOS } from '$lib/shortcuts/key-capture'
45
import Button from '$lib/ui/Button.svelte'
56
67
interface Props {
@@ -15,16 +16,20 @@
1516
<div class="icon"><DotLottieSvelte src="/icons/lock-closing.lottie" autoplay speed={0.5} /></div>
1617
<h2>No permission</h2>
1718
<p class="folder-path">{folderPath}</p>
18-
<p>If you want to see the content of this folder:</p>
19-
<ol>
20-
<li>Click <strong>Open System Settings</strong> below</li>
21-
<li>Click <strong>Files & Folders</strong> in the list</li>
22-
<li>Find <strong>Cmdr</strong> and toggle the switch for this folder.</li>
23-
<li>Confirm it and click <strong>Quit & Reopen</strong></li>
24-
</ol>
25-
<div class="cta">
26-
<Button variant="primary" onclick={() => openPrivacySettings()}>Open System Settings</Button>
27-
</div>
19+
{#if isMacOS()}
20+
<p>If you want to see the content of this folder:</p>
21+
<ol>
22+
<li>Click <strong>Open System Settings</strong> below</li>
23+
<li>Click <strong>Files & Folders</strong> in the list</li>
24+
<li>Find <strong>Cmdr</strong> and toggle the switch for this folder.</li>
25+
<li>Confirm it and click <strong>Quit & Reopen</strong></li>
26+
</ol>
27+
<div class="cta">
28+
<Button variant="primary" onclick={() => openPrivacySettings()}>Open System Settings</Button>
29+
</div>
30+
{:else}
31+
<p>You don't have permission to read this folder. Check that your user has the right file permissions.</p>
32+
{/if}
2833
</div>
2934
</div>
3035

0 commit comments

Comments
 (0)