From 42cad8782572f1f9f745111e90571b98c89c80ad Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Sat, 22 Mar 2025 02:37:54 -0400 Subject: [PATCH] feat: Open multiple files with one/multiple apps Opening multiple selected paths should: * Launch one app that supports all of the file paths e.g. an image viewer that allows cycling through the selected images * Launch multiple instances of an app if the app doesn't handle multiple URLs, such as nsxiv * Launch different apps for a heterogeneous mix of URLs while taking into account the two rules above. For example, launching a mix of paths of images, videos, and text should open the correct apps --- src/app.rs | 256 +++++++++++++++++++++++++++--------------------- src/mime_app.rs | 129 ++++++++++++++++++++---- src/tab.rs | 23 +++-- 3 files changed, 271 insertions(+), 137 deletions(-) diff --git a/src/app.rs b/src/app.rs index cb373cf..af0e01c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -66,21 +66,21 @@ use wayland_client::{protocol::wl_output::WlOutput, Proxy}; use crate::{ clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, config::{AppTheme, Config, DesktopConfig, Favorite, IconSizes, TabConfig, TypeToSearch}, - dialog::{DialogKind, DialogMessage}, + dialog::{Dialog, DialogKind, DialogMessage, DialogResult}, fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, - menu, mime_app, mime_icon, + menu, + mime_app::{self, MimeApp, MimeAppCache}, + mime_icon, mounter::{MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage, MOUNTERS}, - operation::{Controller, Operation, OperationSelection, ReplaceResult}, + operation::{ + Controller, Operation, OperationError, OperationErrorType, OperationSelection, + ReplaceResult, + }, spawn_detached::spawn_detached, tab::{self, HeadingOptions, ItemMetadata, Location, Tab, HOVER_DURATION}, }; -use crate::{ - dialog::Dialog, - operation::{OperationError, OperationErrorType}, -}; -use crate::{dialog::DialogResult, mime_app::MimeApp}; #[derive(Clone, Debug)] pub enum Mode { @@ -471,7 +471,7 @@ pub enum DialogPage { path: PathBuf, mime: mime_guess::Mime, selected: usize, - store_opt: Option, + store_opt: Option, }, RenameItem { from: PathBuf, @@ -544,7 +544,7 @@ pub struct App { dialog_text_input: widget::Id, key_binds: HashMap, margin: HashMap, - mime_app_cache: mime_app::MimeAppCache, + mime_app_cache: MimeAppCache, modifiers: Modifiers, mounter_items: HashMap, network_drive_connecting: Option<(MounterKey, String)>, @@ -576,22 +576,95 @@ pub struct App { } impl App { - fn open_file(&mut self, path: &PathBuf) { - let mime = mime_icon::mime_for_path(path); + fn open_file(&mut self, paths: &[impl AsRef]) { + // Associate all paths to its MIME type + // This allows handling paths as groups if possible, such as launching a single video + // player that is passed every path. + let mut groups: HashMap> = HashMap::new(); + for (mime, path) in paths + .iter() + .map(|path| (mime_icon::mime_for_path(path), path.as_ref().to_owned())) + { + groups.entry(mime).or_default().push(path); + } - if mime == "application/x-desktop" { - // Try opening desktop application - match freedesktop_entry_parser::parse_entry(path) { - Ok(entry) => match entry.section("Desktop Entry").attr("Exec") { - Some(exec) => match mime_app::exec_to_command(exec, None) { - Some(mut command) => match spawn_detached(&mut command) { - Ok(()) => { - return; + 'outer: for (mime, paths) in groups { + log::debug!("Attempting to launch app\n\tfor: {mime}\n\twith: {paths:?}"); + + // First launch apps that can be launched directly + if mime == "application/x-desktop" { + // Try opening desktop application + App::launch_desktop_entries(&paths); + continue; + } else if mime == "application/x-executable" || mime == "application/vnd.appimage" { + // Try opening executable + for path in paths { + let mut command = std::process::Command::new(&path); + match spawn_detached(&mut command) { + Ok(()) => {} + Err(err) => match err.kind() { + io::ErrorKind::PermissionDenied => { + // If permission is denied, try marking as executable, then running + self.dialog_pages + .push_back(DialogPage::SetExecutableAndLaunch { + path: path.to_path_buf(), + }); } - Err(err) => { + _ => { log::warn!("failed to execute {:?}: {}", path, err); } }, + } + } + + continue; + } + + // Try mime apps, which should be faster than xdg-open + if self.launch_from_mime_cache(&mime, &paths) { + continue; + } + + // loop through subclasses if available + if let Some(mime_sub_classes) = mime_icon::parent_mime_types(&mime) { + for sub_class in mime_sub_classes { + if self.launch_from_mime_cache(&sub_class, &paths) { + continue 'outer; + } + } + } + + // Fall back to using open crate + for path in paths { + match open::that_detached(&path) { + Ok(()) => { + let _ = recently_used_xbel::update_recently_used( + &path, + App::APP_ID.to_string(), + "cosmic-files".to_string(), + None, + ); + } + Err(err) => { + log::warn!("failed to open {:?}: {}", path, err); + } + } + } + } + } + + fn launch_desktop_entries(paths: &[impl AsRef]) { + for path in paths.iter().map(AsRef::as_ref) { + match freedesktop_entry_parser::parse_entry(path) { + Ok(entry) => match entry.section("Desktop Entry").attr("Exec") { + Some(exec) => match mime_app::exec_to_command(exec, &[] as &[&str; 0]) { + Some(commands) => { + for mut command in commands { + if let Err(err) = spawn_detached(&mut command) { + log::warn!("failed to execute {:?}: {}", path, err); + } + } + } None => { log::warn!("failed to parse {:?}: invalid Desktop Entry/Exec", path); } @@ -604,87 +677,48 @@ impl App { log::warn!("failed to parse {:?}: {}", path, err); } } - } else if mime == "application/x-executable" || mime == "application/vnd.appimage" { - // Try opening executable - let mut command = std::process::Command::new(path); - match spawn_detached(&mut command) { - Ok(()) => {} - Err(err) => match err.kind() { - io::ErrorKind::PermissionDenied => { - // If permission is denied, try marking as executable, then running - self.dialog_pages - .push_back(DialogPage::SetExecutableAndLaunch { - path: path.to_path_buf(), - }); - } - _ => { - log::warn!("failed to execute {:?}: {}", path, err); - } - }, - } - return; } + } - // Try mime apps, which should be faster than xdg-open - for app in self.mime_app_cache.get(&mime) { - let Some(mut command) = app.command(Some(path.clone().into())) else { + fn launch_from_mime_cache

(&self, mime: &Mime, paths: &[P]) -> bool + where + P: std::fmt::Debug + AsRef + AsRef, + { + for app in self.mime_app_cache.get(mime) { + let Some(commands) = app.command(paths) else { continue; }; - match spawn_detached(&mut command) { - Ok(()) => { - let _ = recently_used_xbel::update_recently_used( - path, - App::APP_ID.to_string(), - "cosmic-files".to_string(), - None, - ); - return; - } - Err(err) => { + let len = commands.len(); + + for (i, mut command) in commands.into_iter().enumerate() { + if let Err(err) = spawn_detached(&mut command) { + // More than one command: The app doesn't support lists of paths so each command + // is associated with one instance + // + // One command: Attempted to launch one app with multiple paths + let path = if len > 1 { + format!("{:?}", paths.get(i)) + } else { + format!("{paths:?}") + }; log::warn!("failed to open {:?} with {:?}: {}", path, app.id, err); } } - } - // loop through subclasses if available - if let Some(mime_sub_classes) = mime_icon::parent_mime_types(&mime) { - for sub_class in mime_sub_classes { - for app in self.mime_app_cache.get(&sub_class) { - let Some(mut command) = app.command(Some(path.clone().into())) else { - continue; - }; - match spawn_detached(&mut command) { - Ok(()) => { - let _ = recently_used_xbel::update_recently_used( - path, - App::APP_ID.to_string(), - "cosmic-files".to_string(), - None, - ); - return; - } - Err(err) => { - log::warn!("failed to open {:?} with {:?}: {}", path, app.id, err); - } - } - } - } - } - - // Fall back to using open crate - match open::that_detached(path) { - Ok(()) => { + for path in paths { let _ = recently_used_xbel::update_recently_used( - path, + &path.into(), App::APP_ID.to_string(), "cosmic-files".to_string(), None, ); } - Err(err) => { - log::warn!("failed to open {:?}: {}", path, err); - } + + return true; } + + // No app matched for mimes and paths + false } #[cfg(feature = "desktop")] @@ -1769,7 +1803,7 @@ impl Application for App { dialog_text_input: widget::Id::unique(), key_binds, margin: HashMap::new(), - mime_app_cache: mime_app::MimeAppCache::new(), + mime_app_cache: MimeAppCache::new(), modifiers: Modifiers::empty(), mounter_items: HashMap::new(), network_drive_connecting: None, @@ -2307,7 +2341,9 @@ impl Application for App { let all_apps = self.get_programs_for_mime(&mime); if let Some(app) = all_apps.get(selected) { - if let Some(mut command) = app.command(Some(path.clone().into())) { + if let Some(mut command) = + app.command(&[&path]).and_then(|v| v.into_iter().next()) + { match spawn_detached(&mut command) { Ok(()) => { let _ = recently_used_xbel::update_recently_used( @@ -2739,18 +2775,18 @@ impl Application for App { } } for path in paths { - if let Some(mut command) = terminal.command(None) { + if let Some(mut command) = terminal + .command::<&str>(&[]) + .and_then(|v| v.into_iter().next()) + { command.current_dir(&path); - match spawn_detached(&mut command) { - Ok(()) => {} - Err(err) => { - log::warn!( - "failed to open {:?} with terminal {:?}: {}", - path, - terminal.id, - err - ) - } + if let Err(err) = spawn_detached(&mut command) { + log::warn!( + "failed to open {:?} with terminal {:?}: {}", + path, + terminal.id, + err + ) } } else { log::warn!("failed to get command for {:?}", terminal.id); @@ -2800,12 +2836,12 @@ impl Application for App { .. }) => { let url = format!("mime:///{mime}"); - if let Some(mut command) = app.command(Some(url.clone().into())) { - match spawn_detached(&mut command) { - Ok(()) => {} - Err(err) => { - log::warn!("failed to open {:?} with {:?}: {}", url, app.id, err) - } + // TODO: Support multiple URLs + if let Some(mut command) = + app.command(&[&url]).and_then(|v| v.into_iter().next()) + { + if let Err(err) = spawn_detached(&mut command) { + log::warn!("failed to open {:?} with {:?}: {}", url, app.id, err) } } else { log::warn!( @@ -3329,7 +3365,7 @@ impl Application for App { cosmic::action::app(Message::TabMessage(Some(entity), x)) })); } - tab::Command::OpenFile(path) => self.open_file(&path), + tab::Command::OpenFile(paths) => self.open_file(&paths), tab::Command::OpenInNewTab(path) => { commands.push(self.open_tab(Location::Path(path.clone()), false, None)); } @@ -3682,9 +3718,9 @@ impl Application for App { .nav_model .data::(entity) .and_then(|x| x.path_opt()) - .map(|x| x.to_path_buf()) + .map(ToOwned::to_owned) { - self.open_file(&path); + self.open_file(&[path]); } } NavMenuAction::OpenWith(entity) => { @@ -4427,7 +4463,7 @@ impl Application for App { }; let mut column = widget::list_column(); - let available_programs = self.get_programs_for_mime(&mime); + let available_programs = self.get_programs_for_mime(mime); let item_height = 32.0; let mut displayed_default = false; diff --git a/src/mime_app.rs b/src/mime_app.rs index ca6588f..2e6247a 100644 --- a/src/mime_app.rs +++ b/src/mime_app.rs @@ -6,32 +6,123 @@ use cosmic::desktop; use cosmic::widget; pub use mime_guess::Mime; use std::{ - cmp::Ordering, collections::HashMap, env, ffi::OsString, fs, io, path::PathBuf, process, + cmp::Ordering, + collections::HashMap, + env, + ffi::OsStr, + fs, io, + path::{Path, PathBuf}, + process, time::Instant, }; -pub fn exec_to_command(exec: &str, path_opt: Option) -> Option { - let args_vec: Vec = shlex::split(exec)?; - let mut args = args_vec.iter(); - let mut command = process::Command::new(args.next()?); - for arg in args { - if arg.starts_with('%') { - match arg.as_str() { - "%f" | "%F" | "%u" | "%U" => { - if let Some(path) = &path_opt { - command.arg(path); - } +// Supported exec key field codes +const EXEC_HANDLERS: [&str; 4] = ["%f", "%F", "%u", "%U"]; +// Deprecated field codes. The spec advises to ignore these handlers. +const DEPRECATED_HANDLERS: [&str; 6] = ["%d", "%D", "%n", "%N", "%v", "%m"]; + +pub fn exec_to_command( + exec: &str, + path_opt: &[impl AsRef], +) -> Option> { + let args_vec = shlex::split(exec)?; + let program = args_vec.first()?; + + // Base Command instance(s) + // 1. We may need to launch multiple of the same process. + // 2. Each of those processes will need to be passed args from exec. + // 3. Each of those args may appear in any order. + // + // So, we'll go through exec in two passes. The first pass handles paths (%f etc) while the + // second passes extra, non-% args to each processes. + // + // While it'd be marginally faster to process everything in one pass, that's problematic: + // 1. path_opt may need to be cloned because it may be moved on each iteration (borrowck + // doesn't know we'll only use it once) + // 2. We have to keep track of which modifier (%f etc) we've used/seen already + // 3. We have to keep track of which processes received non-modifier args which gets messy fast + // 4. `exec` is likely small so looping over it twice is not a big deal + let args_handler = args_vec + .iter() + .find(|arg| EXEC_HANDLERS.contains(&arg.as_str())); + // msrv + // .inspect(|handler| log::trace!("Found paths handler: {handler} for exec: {exec}")); + let mut processes = match args_handler.map(|s| s.as_str()) { + Some("%f") => { + let mut processes = Vec::with_capacity(path_opt.len()); + + for path in path_opt.iter().map(AsRef::as_ref) { + // TODO: %f and %F need to handle non-file URLs (see spec) + if from_file_or_dir(path).is_none() { + log::warn!("Desktop file expects a file path instead of a URL: {path:?}"); } - _ => { - log::warn!("unsupported Exec code {:?} in {:?}", arg, exec); - return None; + + // Passing multiple paths to %f should open an instance per path + let mut process = process::Command::new(program); + process.arg(path); + processes.push(process); + } + + processes + } + Some("%F") => { + // TODO: %f and %F need to handle non-file URLs (see spec) + for invalid in path_opt + .iter() + .map(AsRef::as_ref) + .filter(|path| from_file_or_dir(path).is_none()) + { + log::warn!("Desktop file expects a file path instead of a URL: {invalid:?}"); + } + + // Launch one instance with all args + let mut process = process::Command::new(program); + process.args(path_opt); + + vec![process] + } + Some("%u") => path_opt + .iter() + .map(|path| { + let mut process = process::Command::new(program); + process.arg(path); + process + }) + .collect(), + Some("%U") => { + let mut process = process::Command::new(program); + process.args(path_opt); + vec![process] + } + Some(invalid) => unreachable!("All valid variants were checked; got: {invalid}"), + None => vec![process::Command::new(program)], + }; + + // Pass 2: Add every argument that's not % to each process + for arg in args_vec.into_iter().skip(1) { + match arg.as_str() { + unsupported + if arg.starts_with('%') + && !EXEC_HANDLERS.contains(&unsupported) + && !DEPRECATED_HANDLERS.contains(&unsupported) => + { + log::warn!("unsupported Exec code {:?} in {:?}", unsupported, exec); + return None; + } + arg => { + for process in &mut processes { + process.arg(arg); } } - } else { - command.arg(arg); } } - Some(command) + Some(processes) +} + +fn from_file_or_dir(path: impl AsRef) -> Option { + url::Url::from_file_path(&path) + .ok() + .or_else(|| url::Url::from_directory_path(&path).ok()) } #[derive(Clone, Debug)] @@ -46,7 +137,7 @@ pub struct MimeApp { impl MimeApp { //TODO: move to libcosmic, support multiple files - pub fn command(&self, path_opt: Option) -> Option { + pub fn command>(&self, path_opt: &[O]) -> Option> { exec_to_command(self.exec.as_deref()?, path_opt) } } diff --git a/src/tab.rs b/src/tab.rs index 0c66a0b..da0bacc 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1143,7 +1143,7 @@ pub enum Command { #[cfg(feature = "desktop")] ExecEntryAction(cosmic::desktop::DesktopEntryData, usize), Iced(TaskWrapper), - OpenFile(PathBuf), + OpenFile(Vec), OpenInNewTab(PathBuf), OpenInNewWindow(PathBuf), OpenTrash, @@ -2361,7 +2361,7 @@ impl Tab { if clicked_item.metadata.is_dir() { cd = Some(location.clone()); } else if let Some(path) = location.path_opt() { - commands.push(Command::OpenFile(path.to_path_buf())); + commands.push(Command::OpenFile(vec![path.to_path_buf()])); } else { log::warn!("no path for item {:?}", clicked_item); } @@ -2467,6 +2467,7 @@ impl Tab { .any(|(e_i, e)| Some(e_i) == click_i_opt.as_ref() && e.selected) }); if let Some(ref mut items) = self.items_opt { + let mut paths_to_open = vec![]; for (i, item) in items.iter_mut().enumerate() { if Some(i) == click_i_opt { // Single click to open. @@ -2475,7 +2476,7 @@ impl Tab { if item.metadata.is_dir() { cd = Some(location.clone()); } else if let Some(path) = location.path_opt() { - commands.push(Command::OpenFile(path.to_path_buf())); + paths_to_open.push(path.to_path_buf()); } else { log::warn!("no path for item {:?}", item); } @@ -2508,6 +2509,9 @@ impl Tab { item.selected = false; } } + if !paths_to_open.is_empty() { + commands.push(Command::OpenFile(paths_to_open)); + } } } } @@ -2897,7 +2901,7 @@ impl Tab { if path.is_dir() { cd = Some(location); } else { - commands.push(Command::OpenFile(path.clone())); + commands.push(Command::OpenFile(vec![path.clone()])); } } _ => { @@ -2920,11 +2924,12 @@ impl Tab { if path.is_dir() { cd = Some(Location::Path(path)); } else { - commands.push(Command::OpenFile(path)); + commands.push(Command::OpenFile(vec![path])); } } None => { if let Some(ref mut items) = self.items_opt { + let mut open_files = Vec::new(); for item in items.iter() { if item.selected { if let Some(location) = &item.location_opt { @@ -2932,13 +2937,15 @@ impl Tab { //TODO: allow opening multiple tabs? cd = Some(location.clone()); } else if let Some(path) = location.path_opt() { - commands.push(Command::OpenFile(path.to_path_buf())); + open_files.push(path.to_path_buf()); } } else { //TODO: open properties? } } } + + commands.push(Command::OpenFile(open_files)); } } } @@ -2978,7 +2985,7 @@ impl Tab { //cd = Some(Location::Path(path.clone())); commands.push(Command::OpenInNewTab(path.clone())) } else { - commands.push(Command::OpenFile(path.clone())); + commands.push(Command::OpenFile(vec![path.clone()])); } } else { log::warn!("no path for item {:?}", clicked_item); @@ -3310,7 +3317,7 @@ impl Tab { if matches!(self.mode, Mode::Desktop) { match location { Location::Path(path) => { - commands.push(Command::OpenFile(path)); + commands.push(Command::OpenFile(vec![path])); } Location::Trash => { commands.push(Command::OpenTrash);