Skip to content

Commit 71e6061

Browse files
committed
Context menu: Open with, cloud actions, Services menu
- "Open with" submenu in the file context menu, populated via `NSWorkspace.URLsForApplicationsToOpenURL:`. Multi-selection intersects per-file candidates and launches all paths in one `openURLs:withApplicationAtURL:configuration:` call. App-bundle icons rendered via `IconMenuItem` (full-color, not template). Default app marked with " (default)" plain-text suffix; will dim once `tauri-apps/muda#353` lands and Tauri exposes the typed setter. - Session-scoped candidate cache keyed by lowercased extension, invalidated on `NSWorkspace.didLaunchApplicationNotification` / `didTerminateApplicationNotification`. - "Open with → Other..." opens a native `NSOpenPanel` filtered to `.app` bundles. - Worker threads use 8 MB stacks (FileProvider XPC stack-depth pattern from `sync_status.rs`). - Cloud actions ("Make available offline" / "Remove download") gated on cloud-storage paths and current sync status. Wraps `NSFileProviderManager.evictItem` / `requestDownloadForItem` async APIs through completion handler + `mpsc::sync_channel`. Diagnostic logging via `log::warn!` with `target: "cloud_actions"` captures NSError code/domain on failure. - File context menu's "Copy 'filename'" item truncates with middle ellipsis at 50 chars (preserves extension) so long filenames don't blow up the menu width. - System Services menu in the cmdr app menu via `PredefinedMenuItem::services` — populated by AppKit with services registered by other apps. - Open-with click events are prefix-routed (`open-with:<bundle-id>`) before the unified `menu_id_to_command` lookup, since per-app IDs are too dynamic to enumerate. Bundle-id → app-path map cached on `MenuState.context.open_with_apps` per right-click. - Frontend command registry adds `cloud.makeOffline` / `cloud.removeDownload` (macOS-only in palette); `command-dispatch` routes them to the new Tauri commands. - File context menu now passes the full multi-selection paths so cloud + open-with actions apply to the whole selection (Finder convention: action follows selection if right-clicked entry is part of it).
1 parent cb88685 commit 71e6061

20 files changed

Lines changed: 1373 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 2 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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,15 @@ urlencoding = "2.1.3"
150150
objc2 = { version = "0.6", features = ["std", "exception"] }
151151
objc2-foundation = { version = "0.3", features = [
152152
"NSURL", "NSString", "NSDictionary", "NSDate", "NSArray", "NSValue", "NSError",
153-
"NSFileManager", "NSNotification",
153+
"NSFileManager", "NSNotification", "NSBundle",
154154
] }
155155
objc2-app-kit = { version = "0.3", features = [
156156
"NSDragging", "NSDraggingItem", "NSImage", "NSColor", "NSColorSpace",
157157
"NSApplication", "NSMenu", "NSMenuItem", "NSPasteboard", "NSRunningApplication",
158+
"NSWorkspace", "NSPanel", "NSOpenPanel", "NSSavePanel", "NSWindow", "NSResponder",
159+
] }
160+
objc2-file-provider = { version = "0.3", features = [
161+
"Extension", "NSFileProviderDomain", "NSFileProviderItem", "NSFileProviderError",
158162
] }
159163
block2 = "0.6"
160164
security-framework = "3.2"

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

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::ignore_poison::IgnorePoison;
22
use crate::menu::{
3-
CLOSE_TAB_ID, CommandScope, MenuState, build_breadcrumb_context_menu, build_context_menu,
3+
CLOSE_TAB_ID, CommandScope, FileContextInfo, MenuState, build_breadcrumb_context_menu, build_context_menu,
44
build_network_host_context_menu, build_tab_context_menu, frontend_shortcut_to_accelerator, menu_id_to_command,
55
};
66
#[cfg(any(target_os = "macos", target_os = "linux"))]
@@ -23,18 +23,82 @@ pub fn show_file_context_menu<R: Runtime>(
2323
path: String,
2424
filename: String,
2525
is_directory: bool,
26+
paths: Vec<String>,
2627
) -> Result<(), String> {
2728
let app = window.app_handle();
2829

29-
// Update context first so menu events have the right data
30-
update_menu_context(app.clone(), path, filename.clone());
30+
// The "primary" path drives single-file actions like "Copy 'filename'", Get info,
31+
// Quick look. `paths` carries the full selection that "Open with" and cloud actions
32+
// should apply to — it equals `[path]` when the right-clicked file isn't part of a
33+
// multi-selection, or the entire selection otherwise.
34+
let context_paths = if paths.is_empty() { vec![path.clone()] } else { paths };
35+
36+
// Compute per-file context (sync status, FP-domain membership, candidate "Open with"
37+
// apps). The LaunchServices query for candidates can take 50-200 ms on a cold cache,
38+
// which delays the popup; the cache (in `file_system::open_with`) keeps later
39+
// right-clicks fast.
40+
#[cfg(target_os = "macos")]
41+
let info = build_file_context_info(&path, &context_paths);
42+
#[cfg(not(target_os = "macos"))]
43+
let info = FileContextInfo;
44+
45+
// Update menu context so on_menu_event has paths + bundle map for the new items.
46+
{
47+
let state = app.state::<MenuState<R>>();
48+
let mut context = state.context.lock_ignore_poison();
49+
context.path = path.clone();
50+
context.filename = filename.clone();
51+
context.paths = context_paths;
52+
#[cfg(target_os = "macos")]
53+
{
54+
// Filled in from build_context_menu's return value below.
55+
context.open_with_apps.clear();
56+
}
57+
}
3158

32-
let menu = build_context_menu(app, &filename, is_directory).map_err(|e| e.to_string())?;
33-
menu.popup(window).map_err(|e| e.to_string())?;
59+
let result = build_context_menu(app, &filename, is_directory, &info).map_err(|e| e.to_string())?;
60+
61+
// Stash the bundle_id → app_path map so on_menu_event can resolve clicks on
62+
// `open-with:<bundle-id>` items back to a real app URL.
63+
#[cfg(target_os = "macos")]
64+
{
65+
let state = app.state::<MenuState<R>>();
66+
let mut context = state.context.lock_ignore_poison();
67+
context.open_with_apps = result.open_with_apps;
68+
}
69+
70+
result.menu.popup(window).map_err(|e| e.to_string())?;
3471

3572
Ok(())
3673
}
3774

75+
#[cfg(target_os = "macos")]
76+
fn build_file_context_info(primary_path: &str, all_paths: &[String]) -> FileContextInfo {
77+
use crate::file_system::cloud_actions::is_in_cloud_storage;
78+
use crate::file_system::open_with::compute_open_with_choices;
79+
use crate::file_system::sync_status::get_sync_statuses;
80+
use std::path::PathBuf;
81+
82+
let path_buf = PathBuf::from(primary_path);
83+
let is_cloud = is_in_cloud_storage(&path_buf);
84+
85+
// Sync status of the primary path only — drives the cloud-action label.
86+
let sync_status = if is_cloud {
87+
let mut statuses = get_sync_statuses(vec![primary_path.to_string()]);
88+
statuses.remove(primary_path).unwrap_or_default()
89+
} else {
90+
Default::default()
91+
};
92+
93+
let open_with = compute_open_with_choices(all_paths.iter().map(PathBuf::from).collect());
94+
95+
FileContextInfo {
96+
sync_status,
97+
is_cloud,
98+
open_with,
99+
}
100+
}
101+
38102
/// Shows a native context menu for the breadcrumb path bar.
39103
/// The `shortcut` is the user's configured shortcut in frontend format (e.g. "⌃⌘C"),
40104
/// or empty string if no shortcut is configured.
@@ -209,6 +273,27 @@ pub fn open_in_editor(path: String) -> Result<(), String> {
209273
Ok(())
210274
}
211275

276+
/// Make a cloud-managed file available offline (download it). On macOS, talks to the
277+
/// File Provider extension responsible for the file (iCloud Drive, Dropbox, GDrive,
278+
/// OneDrive, Box, etc.).
279+
#[tauri::command]
280+
pub async fn cloud_make_available_offline(path: String) -> Result<(), String> {
281+
tokio::task::spawn_blocking(move || {
282+
crate::file_system::cloud_actions::request_download(std::path::Path::new(&path))
283+
})
284+
.await
285+
.map_err(|e| e.to_string())?
286+
}
287+
288+
/// Evict a cloud-managed file's local copy, leaving a placeholder. Counterpart to
289+
/// `cloud_make_available_offline`.
290+
#[tauri::command]
291+
pub async fn cloud_remove_download(path: String) -> Result<(), String> {
292+
tokio::task::spawn_blocking(move || crate::file_system::cloud_actions::evict_item(std::path::Path::new(&path)))
293+
.await
294+
.map_err(|e| e.to_string())?
295+
}
296+
212297
#[tauri::command]
213298
#[cfg(target_os = "linux")]
214299
pub fn open_in_editor(path: String) -> Result<(), String> {

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ Core filesystem operations: directory listing, file writing, sync status, volume
44

55
Submodule docs: [listing/](listing/CLAUDE.md), [write_operations/](write_operations/CLAUDE.md), [volume/](volume/CLAUDE.md).
66

7+
## Cloud actions and "Open with" (macOS)
8+
9+
- `cloud_actions.rs` — wraps `NSFileProviderManager.evictItem(...)` and
10+
`requestDownloadForItem(...)` so the file context menu can offer "Make available offline" and
11+
"Remove download" for any File-Provider-managed file (iCloud Drive, Dropbox, Google Drive,
12+
OneDrive, Box). Detection is fast (`is_in_cloud_storage` — pure path-prefix check against
13+
`~/Library/Mobile Documents/com~apple~CloudDocs` and `~/Library/CloudStorage/`); the actual
14+
evict/download chain calls async FP APIs synchronously via completion handlers + `mpsc::sync_channel`.
15+
- `open_with.rs``URLsForApplicationsToOpenURL:` for candidate apps, with multi-selection
16+
intersection. Session cache keyed by lowercased extension. Subscribes to
17+
`NSWorkspace.didLaunchApplicationNotification` / `didTerminateApplicationNotification` for
18+
invalidation (per AGENTS.md "Subscribe, don't poll" — TTL is fallback only). `open_paths_with`
19+
launches with one multi-URL `openURLs:withApplicationAtURL:configuration:completionHandler:`
20+
call. `pick_app_via_open_panel` shows `NSOpenPanel` filtered to `.app` bundles for the
21+
"Open with → Other..." entry. Worker threads use 8 MB stacks (FileProvider XPC depth).
22+
723
## Gotchas
824

925
**Never use rayon (or any constrained-stack thread pool) for calls into macOS frameworks.**

0 commit comments

Comments
 (0)