Partial open with implementation, parse mimeapps.list files

This commit is contained in:
Jeremy Soller 2024-03-01 16:10:30 -07:00
parent 18e847abb8
commit 26173d6529
No known key found for this signature in database
GPG key ID: D02FD439211AF56F
11 changed files with 357 additions and 147 deletions

View file

@ -58,6 +58,7 @@ pub enum Action {
NewFile,
NewFolder,
Open,
OpenWith,
Operations,
Paste,
Properties,
@ -94,6 +95,7 @@ impl Action {
Action::NewFile => Message::NewItem(entity_opt, false),
Action::NewFolder => Message::NewItem(entity_opt, true),
Action::Open => Message::TabMessage(entity_opt, tab::Message::Open),
Action::OpenWith => Message::ToggleContextPage(ContextPage::OpenWith),
Action::Operations => Message::ToggleContextPage(ContextPage::Operations),
Action::Paste => Message::Paste(entity_opt),
Action::Properties => Message::ToggleContextPage(ContextPage::Properties),
@ -159,6 +161,7 @@ pub enum Message {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContextPage {
About,
OpenWith,
Operations,
Properties,
Settings,
@ -168,6 +171,7 @@ impl ContextPage {
fn title(&self) -> String {
match self {
Self::About => String::new(),
Self::OpenWith => fl!("open-with"),
Self::Operations => fl!("operations"),
Self::Properties => fl!("properties"),
Self::Settings => fl!("settings"),
@ -380,6 +384,24 @@ impl App {
.into()
}
fn open_with(&self) -> Element<Message> {
let mut children = Vec::new();
let entity = self.tab_model.active();
if let Some(tab) = self.tab_model.data::<Tab>(entity) {
if let Some(items) = tab.items_opt() {
for item in items.iter() {
if item.selected {
children.push(item.open_with_view(tab.config.icon_sizes));
// Only show one property view to avoid issues like hangs when generating
// preview images on thousands of files
break;
}
}
}
}
widget::settings::view_column(children).into()
}
fn operations(&self) -> Element<Message> {
let mut children = Vec::new();
@ -1101,6 +1123,7 @@ impl Application for App {
Some(match self.context_page {
ContextPage::About => self.about(),
ContextPage::OpenWith => self.open_with(),
ContextPage::Operations => self.operations(),
ContextPage::Properties => self.properties(),
ContextPage::Settings => self.settings(),

View file

@ -16,12 +16,11 @@ pub mod dialog;
mod key_bind;
mod localize;
mod menu;
mod mime_app;
mod mime_icon;
mod mouse_area;
mod operation;
mod tab;
#[cfg(feature = "xdg")]
mod xdg;
pub fn home_dir() -> PathBuf {
match dirs::home_dir() {

View file

@ -66,7 +66,9 @@ pub fn context_menu<'a>(
Location::Path(_) => {
if selected > 0 {
children.push(menu_item(fl!("open"), Action::Open).into());
//TODO: Open with
if selected == 1 {
children.push(menu_item(fl!("open-with"), Action::OpenWith).into());
}
children.push(horizontal_rule(1).into());
children.push(menu_item(fl!("rename"), Action::Rename).into());
children.push(menu_item(fl!("cut"), Action::Cut).into());

204
src/mime_app.rs Normal file
View file

@ -0,0 +1,204 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
#[cfg(feature = "desktop")]
use cosmic::desktop;
use cosmic::widget;
pub use mime_guess::Mime;
use once_cell::sync::Lazy;
use std::{collections::HashMap, path::PathBuf, sync::Mutex, time::Instant};
#[derive(Clone, Debug)]
pub struct MimeApp {
pub id: String,
pub path: Option<PathBuf>,
pub name: String,
pub exec: Option<String>,
pub icon: widget::icon::Handle,
pub is_default: bool,
}
#[cfg(feature = "desktop")]
impl From<&desktop::DesktopEntryData> for MimeApp {
fn from(app: &desktop::DesktopEntryData) -> Self {
Self {
id: app.id.clone(),
path: app.path.clone(),
name: app.name.clone(),
exec: app.exec.clone(),
icon: match &app.icon {
desktop::IconSource::Name(name) => widget::icon::from_name(name.as_str()).handle(),
desktop::IconSource::Path(path) => widget::icon::from_path(path.clone()),
},
is_default: false,
}
}
}
#[cfg(feature = "desktop")]
fn filename_eq(path_opt: &Option<PathBuf>, filename: &str) -> bool {
path_opt
.as_ref()
.and_then(|path| path.file_name())
.map(|x| x == filename)
.unwrap_or(false)
}
pub struct MimeAppCache {
cache: HashMap<Mime, Vec<MimeApp>>,
}
impl MimeAppCache {
pub fn new() -> Self {
let mut mime_app_cache = Self {
cache: HashMap::new(),
};
mime_app_cache.reload();
mime_app_cache
}
#[cfg(not(feature = "desktop"))]
pub fn reload(&mut self) {}
// Only available when using desktop feature of libcosmic, which only works on Unix-likes
#[cfg(feature = "desktop")]
pub fn reload(&mut self) {
let start = Instant::now();
self.cache.clear();
//TODO: get proper locale?
let locale = None;
// Load desktop applications by supported mime types
//TODO: hashmap for all apps by id?
let all_apps = desktop::load_applications(locale, false);
for app in all_apps.iter() {
for mime in app.mime_types.iter() {
let apps = self
.cache
.entry(mime.clone())
.or_insert_with(|| Vec::with_capacity(1));
if apps.iter().find(|x| x.id == app.id).is_none() {
apps.push(MimeApp::from(app));
}
}
}
// Load mimeapps.list files
// https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html
//TODO: support lookup by desktop (colon separated list in $XDG_CURRENT_DESKTOP, converted to lowercase)
let mut mimeapps_paths = Vec::new();
match xdg::BaseDirectories::new() {
Ok(xdg_dirs) => {
for path in xdg_dirs.find_data_files("applications/mimeapps.list") {
mimeapps_paths.push(path);
}
for path in xdg_dirs.find_config_files("mimeapps.list") {
mimeapps_paths.push(path);
}
}
Err(err) => {
log::warn!("failed to get xdg base directories: {}", err);
}
}
//TODO: handle directory specific behavior
for path in mimeapps_paths {
let entry = match freedesktop_entry_parser::parse_entry(&path) {
Ok(ok) => ok,
Err(err) => {
log::warn!("failed to parse {:?}: {}", path, err);
continue;
}
};
for attr in entry.section("Added Associations").attrs() {
if let Ok(mime) = attr.name.parse::<Mime>() {
if let Some(filenames) = attr.value {
for filename in filenames.split_terminator(';') {
println!("Add {}={}", mime, filename);
let apps = self
.cache
.entry(mime.clone())
.or_insert_with(|| Vec::with_capacity(1));
if apps
.iter()
.find(|x| filename_eq(&x.path, filename))
.is_none()
{
if let Some(app) =
all_apps.iter().find(|x| filename_eq(&x.path, filename))
{
apps.push(MimeApp::from(app));
} else {
log::warn!("failed to find application {:?}", filename);
}
}
}
}
}
}
for attr in entry.section("Removed Associations").attrs() {
if let Ok(mime) = attr.name.parse::<Mime>() {
if let Some(filenames) = attr.value {
for filename in filenames.split_terminator(';') {
println!("Remove {}={}", mime, filename);
if let Some(apps) = self.cache.get_mut(&mime) {
apps.retain(|x| !filename_eq(&x.path, filename));
}
}
}
}
}
for attr in entry.section("Default Applications").attrs() {
if let Ok(mime) = attr.name.parse::<Mime>() {
if let Some(filenames) = attr.value {
for filename in filenames.split_terminator(';') {
println!("Default {}={}", mime, filename);
if let Some(apps) = self.cache.get_mut(&mime) {
let mut found = false;
for app in apps.iter_mut() {
if filename_eq(&app.path, filename) {
app.is_default = true;
found = true;
} else {
app.is_default = false;
}
}
if found {
break;
} else {
log::warn!("failed to find application {:?}", filename);
}
}
}
}
}
}
}
// Sort apps by name
for apps in self.cache.values_mut() {
apps.sort_by(|a, b| lexical_sort::natural_lexical_cmp(&a.name, &b.name));
}
let elapsed = start.elapsed();
log::info!("loaded mime app cache in {:?}", elapsed);
}
pub fn get(&self, key: &Mime) -> Vec<MimeApp> {
self.cache
.get(&key)
.map_or_else(|| Vec::new(), |x| x.clone())
}
}
static MIME_APP_CACHE: Lazy<Mutex<MimeAppCache>> = Lazy::new(|| Mutex::new(MimeAppCache::new()));
pub fn mime_apps(mime: &Mime) -> Vec<MimeApp> {
let mime_app_cache = MIME_APP_CACHE.lock().unwrap();
mime_app_cache.get(mime)
}

View file

@ -20,20 +20,17 @@ use cosmic::{
},
theme, widget, Element,
};
use mime_guess::MimeGuess;
use mime_guess::{mime, Mime, MimeGuess};
use once_cell::sync::Lazy;
use std::{
cell::Cell,
cmp::Ordering,
collections::HashMap,
fmt,
fs::{self, Metadata},
path::PathBuf,
time::{Duration, Instant},
};
#[cfg(feature = "xdg")]
use crate::xdg::{mime_apps, DesktopEntryData};
use crate::{
app::Action,
config::{IconSizes, TabConfig},
@ -41,6 +38,7 @@ use crate::{
fl,
key_bind::KeyBind,
menu,
mime_app::{mime_apps, MimeApp},
mime_icon::mime_icon,
mouse_area,
};
@ -224,23 +222,39 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let path = entry.path();
let mime_guess = MimeGuess::from_path(&path);
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
if metadata.is_dir() {
(
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
)
} else {
(
//TODO: best fallback mime for files?
MimeGuess::from_path(&path).first_or_octet_stream(),
mime_icon(&path, sizes.grid()),
mime_icon(&path, sizes.list()),
mime_icon(&path, sizes.list_condensed()),
)
};
let mut open_with = mime_apps(&mime);
if open_with.is_empty() {
//TODO: more fallbacks
if mime.type_() == "text" {
open_with = mime_apps(&mime::TEXT_PLAIN);
}
}
let thumbnail_res_opt = if mime.type_() == "image" {
None
} else {
Some(Err(()))
};
let children = if metadata.is_dir() {
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
@ -259,19 +273,12 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
metadata: ItemMetadata::Path { metadata, children },
hidden,
path,
mime_guess,
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
#[cfg(feature = "xdg")]
open_with: mime_guess
.first()
.map(|mime| mime_apps(&mime))
.unwrap_or_default(),
thumbnail_res_opt: match mime_guess.first() {
Some(mime) if mime.type_() == "image" => None,
_ => Some(Err(())),
},
open_with,
thumbnail_res_opt,
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
@ -333,16 +340,18 @@ pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
let path = entry.original_path();
let name = entry.name.clone();
let mime_guess = MimeGuess::from_path(&path);
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
match metadata.size {
trash::TrashItemSize::Entries(_) => (
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
),
trash::TrashItemSize::Bytes(_) => (
//TODO: best fallback mime for files?
MimeGuess::from_path(&path).first_or_octet_stream(),
mime_icon(&path, sizes.grid()),
mime_icon(&path, sizes.list()),
mime_icon(&path, sizes.list_condensed()),
@ -354,11 +363,10 @@ pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
metadata: ItemMetadata::Trash { metadata, entry },
hidden: false,
path,
mime_guess,
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
#[cfg(feature = "xdg")]
open_with: Vec::new(),
thumbnail_res_opt: Some(Err(())),
button_id: widget::Id::unique(),
@ -457,18 +465,17 @@ impl ItemMetadata {
}
}
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Item {
pub name: String,
pub metadata: ItemMetadata,
pub hidden: bool,
pub path: PathBuf,
pub mime_guess: MimeGuess,
pub mime: Mime,
pub icon_handle_grid: widget::icon::Handle,
pub icon_handle_list: widget::icon::Handle,
pub icon_handle_list_condensed: widget::icon::Handle,
#[cfg(feature = "xdg")]
pub open_with: Vec<DesktopEntryData>,
pub open_with: Vec<MimeApp>,
pub thumbnail_res_opt: Option<Result<image::RgbaImage, ()>>,
pub button_id: widget::Id,
pub pos_opt: Cell<Option<(usize, usize)>>,
@ -478,20 +485,47 @@ pub struct Item {
}
impl Item {
pub fn open_with_view(&self, sizes: IconSizes) -> Element<crate::app::Message> {
let cosmic_theme::Spacing {
space_xs,
space_xxxs,
..
} = theme::active().cosmic().spacing;
let mut column = widget::column().spacing(space_xxxs);
column = column.push(widget::text::heading(&self.name));
for app in self.open_with.iter() {
column = column.push(
widget::button(
widget::row::with_children(vec![
widget::icon(app.icon.clone()).into(),
widget::text(&app.name).into(),
])
.spacing(space_xs),
)
.padding(space_xs)
.width(Length::Fill),
);
}
column.into()
}
pub fn property_view(&self, sizes: IconSizes) -> Element<crate::app::Message> {
let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing;
let mut column = widget::column().spacing(space_xxxs);
let is_image = if let Some(mime) = self.mime_guess.first() {
mime.type_() == "image" && self.path.is_file()
} else {
false
};
column = column.push(widget::row::with_children(vec![
widget::horizontal_space(Length::Fill).into(),
if is_image {
// This loads the image only if thumbnailing worked
if self
.thumbnail_res_opt
.as_ref()
.map_or(false, |res| res.is_ok())
{
widget::image::viewer(widget::image::Handle::from_path(&self.path))
.min_scale(1.0)
.into()
@ -506,9 +540,7 @@ impl Item {
column = column.push(widget::text::heading(self.name.clone()));
if let Some(mime) = self.mime_guess.first() {
column = column.push(widget::text(format!("Type: {}", mime)));
}
column = column.push(widget::text(format!("Type: {}", self.mime)));
//TODO: translate!
//TODO: correct display of folder size?
@ -553,27 +585,6 @@ impl Item {
}
}
impl fmt::Debug for Item {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut d = f.debug_struct("Item");
d.field("name", &self.name);
d.field("metadata", &self.metadata);
d.field("hidden", &self.hidden);
d.field("path", &self.path);
d.field("mime_guess", &self.mime_guess);
// icon_handles
#[cfg(feature = "xdg")]
d.field("open_with", &self.open_with);
// thumbnail_res_opt
d.field("button_id", &self.button_id);
d.field("pos_opt", &self.pos_opt);
d.field("rect_opt", &self.rect_opt);
d.field("selected", &self.selected);
d.field("click_time", &self.click_time);
d.finish()
}
}
#[derive(Clone, Copy, Debug)]
pub enum View {
Grid,

View file

@ -1,58 +0,0 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub use cosmic::desktop::DesktopEntryData;
use cosmic::desktop::{load_applications, Mime};
use once_cell::sync::Lazy;
use std::{collections::HashMap, sync::Mutex, time::Instant};
pub struct MimeAppCache {
cache: HashMap<Mime, Vec<DesktopEntryData>>,
empty: Vec<DesktopEntryData>,
}
impl MimeAppCache {
pub fn new() -> Self {
let mut mime_app_cache = Self {
cache: HashMap::new(),
empty: Vec::new(),
};
mime_app_cache.reload();
mime_app_cache
}
pub fn reload(&mut self) {
let start = Instant::now();
self.cache.clear();
//TODO: get proper locale?
let locale = None;
for app in load_applications(locale, false) {
for mime_type in app.mime_types.iter() {
self.cache
.entry(mime_type.clone())
.or_insert_with(|| Vec::with_capacity(1))
.push(app.clone());
}
}
for apps in self.cache.values_mut() {
apps.sort_by(|a, b| lexical_sort::natural_lexical_cmp(&a.name, &b.name));
}
let elapsed = start.elapsed();
log::info!("loaded mime app cache in {:?}", elapsed);
}
pub fn get(&self, key: &Mime) -> &Vec<DesktopEntryData> {
self.cache.get(&key).unwrap_or_else(|| &self.empty)
}
}
static MIME_APP_CACHE: Lazy<Mutex<MimeAppCache>> = Lazy::new(|| Mutex::new(MimeAppCache::new()));
pub fn mime_apps(mime: &Mime) -> Vec<DesktopEntryData> {
let mime_app_cache = MIME_APP_CACHE.lock().unwrap();
mime_app_cache.get(mime).clone()
}

View file

@ -1,5 +0,0 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod mime_app;
pub use mime_app::*;