Merge pull request #891 from joshuamegnauth54/feat-multiple-uris-single-app

feat: Open multiple files with one/multiple apps
This commit is contained in:
Jeremy Soller 2025-03-31 13:08:58 -06:00 committed by GitHub
commit 3f03f81ccc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 271 additions and 137 deletions

View file

@ -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<mime_app::MimeApp>,
store_opt: Option<MimeApp>,
},
RenameItem {
from: PathBuf,
@ -544,7 +544,7 @@ pub struct App {
dialog_text_input: widget::Id,
key_binds: HashMap<KeyBind, Action>,
margin: HashMap<window::Id, (f32, f32, f32, f32)>,
mime_app_cache: mime_app::MimeAppCache,
mime_app_cache: MimeAppCache,
modifiers: Modifiers,
mounter_items: HashMap<MounterKey, MounterItems>,
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<Path>]) {
// 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<Mime, Vec<PathBuf>> = 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<Path>]) {
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<P>(&self, mime: &Mime, paths: &[P]) -> bool
where
P: std::fmt::Debug + AsRef<Path> + AsRef<std::ffi::OsStr>,
{
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::<Location>(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;

View file

@ -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<OsString>) -> Option<process::Command> {
let args_vec: Vec<String> = 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<OsStr>],
) -> Option<Vec<process::Command>> {
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<Path>) -> Option<url::Url> {
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<OsString>) -> Option<process::Command> {
pub fn command<O: AsRef<OsStr>>(&self, path_opt: &[O]) -> Option<Vec<process::Command>> {
exec_to_command(self.exec.as_deref()?, path_opt)
}
}

View file

@ -1143,7 +1143,7 @@ pub enum Command {
#[cfg(feature = "desktop")]
ExecEntryAction(cosmic::desktop::DesktopEntryData, usize),
Iced(TaskWrapper),
OpenFile(PathBuf),
OpenFile(Vec<PathBuf>),
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);