From 39281a633654d1c31702a66e1e69d9bd497b09a0 Mon Sep 17 00:00:00 2001 From: darkfated Date: Mon, 6 Apr 2026 06:16:27 +0300 Subject: [PATCH 1/2] Add user-defined context actions --- i18n/en/cosmic_files.ftl | 7 +++ src/app.rs | 104 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 4 ++ src/context_action.rs | 83 +++++++++++++++++++++++++++++++ src/dialog.rs | 63 ++++++++++++++++++++++-- src/lib.rs | 1 + src/menu.rs | 21 +++++++- src/tab.rs | 31 ++++++++++-- 8 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 src/context_action.rs diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 6a852bb..69a76c6 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -99,6 +99,13 @@ open-with-title = How do you want to open "{$name}"? browse-store = Browse {$store} other-apps = Other applications related-apps = Related applications +context-action = Context action +context-action-confirm-title = Run "{$name}"? +context-action-confirm-warning = This will run on {$items} {$items -> + [one] item + *[other] items + }. +run = Run ## Permanently delete Dialog selected-items = The {$items} selected items diff --git a/src/app.rs b/src/app.rs index 074e4eb..9d4f3fb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -80,6 +80,7 @@ use crate::{ AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, TimeConfig, TypeToSearch, }, + context_action, dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings}, fl, home_dir, key_bind::key_binds, @@ -109,6 +110,9 @@ static DELETE_TRASH_BUTTON_ID: LazyLock = static CONFIRM_OPEN_WITH_BUTTON_ID: LazyLock = LazyLock::new(|| widget::Id::new("confirm-open-with-button")); +static CONFIRM_CONTEXT_ACTION_BUTTON_ID: LazyLock = + LazyLock::new(|| widget::Id::new("confirm-context-action-button")); + static EMPTY_TRASH_BUTTON_ID: LazyLock = LazyLock::new(|| widget::Id::new("empty-trash-button")); @@ -180,6 +184,7 @@ pub enum Action { OpenItemLocation, OpenTerminal, OpenWith, + RunContextAction(usize), Paste, PermanentlyDelete, Preview, @@ -252,6 +257,9 @@ impl Action { Self::OpenItemLocation => Message::OpenItemLocation(entity_opt), Self::OpenTerminal => Message::OpenTerminal(entity_opt), Self::OpenWith => Message::OpenWithDialog(entity_opt), + Self::RunContextAction(action) => { + Message::TabMessage(entity_opt, tab::Message::RunContextAction(*action)) + } Self::Paste => Message::Paste(entity_opt), Self::PermanentlyDelete => Message::PermanentlyDelete(entity_opt), Self::Preview => Message::Preview(entity_opt), @@ -323,6 +331,7 @@ pub enum NavMenuAction { OpenInNewTab(segmented_button::Entity), OpenInNewWindow(segmented_button::Entity), Preview(segmented_button::Entity), + RunContextAction(segmented_button::Entity, usize), RemoveFromSidebar(segmented_button::Entity), } @@ -558,6 +567,10 @@ pub enum DialogPage { name: String, dir: bool, }, + RunContextAction { + action: usize, + paths: Box<[PathBuf]>, + }, OpenWith { path: PathBuf, mime: mime_guess::Mime, @@ -2593,6 +2606,28 @@ impl Application for App { NavMenuAction::OpenInNewWindow(entity), )); } + if let Some(path) = location_opt.and_then(Location::path_opt) { + let selected_dir = usize::from(path.is_dir()); + let action_items: Vec<_> = self + .config + .context_actions + .iter() + .enumerate() + .filter(|(_, action)| action.matches_selection(1, selected_dir)) + .map(|(i, action)| { + cosmic::widget::menu::Item::Button( + action.name.clone(), + None, + NavMenuAction::RunContextAction(entity, i), + ) + }) + .collect(); + + if !action_items.is_empty() { + items.push(cosmic::widget::menu::Item::Divider); + items.extend(action_items); + } + } items.push(cosmic::widget::menu::Item::Divider); if matches!(location_opt, Some(Location::Path(..))) { items.push(cosmic::widget::menu::Item::Button( @@ -3185,6 +3220,9 @@ impl Application for App { Operation::NewFile { path } })); } + DialogPage::RunContextAction { action, paths } => { + context_action::run(&self.config.context_actions, action, &paths); + } DialogPage::OpenWith { path, mime, @@ -4505,6 +4543,25 @@ impl Application for App { tab::Command::ExecEntryAction(entry, action) => { Self::exec_entry_action(&entry, action); } + tab::Command::RunContextAction(action) => { + let paths: Box<[_]> = self.selected_paths(Some(entity)).collect(); + if let Some(preset) = self.config.context_actions.get(action) { + if preset.confirm { + commands.push(self.push_dialog( + DialogPage::RunContextAction { action, paths }, + Some(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()), + )); + } else { + context_action::run( + &self.config.context_actions, + action, + &paths, + ); + } + } else { + log::warn!("invalid context action index `{action}`"); + } + } tab::Command::Iced(iced_command) => { commands.push(iced_command.0.map(move |x| { cosmic::action::app(Message::TabMessage(Some(entity), x)) @@ -5012,6 +5069,30 @@ impl Application for App { } } } + NavMenuAction::RunContextAction(entity, action) => { + if let Some(path) = self + .nav_model + .data::(entity) + .and_then(Location::path_opt) + .cloned() + { + let paths = vec![path]; + if let Some(preset) = self.config.context_actions.get(action) { + if preset.confirm { + return self.push_dialog( + DialogPage::RunContextAction { + action, + paths: paths.into_boxed_slice(), + }, + Some(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()), + ); + } + context_action::run(&self.config.context_actions, action, &paths); + } else { + log::warn!("invalid context action index `{action}`"); + } + } + } NavMenuAction::OpenInNewTab(entity) => { let open_task = match self.nav_model.data::(entity) { Some(Location::Network(uri, display_name, path)) => self.open_tab( @@ -5788,6 +5869,26 @@ impl Application for App { .spacing(space_xxs), ) } + DialogPage::RunContextAction { action, paths } => { + let name = self + .config + .context_actions + .get(*action) + .map_or_else(|| fl!("context-action"), |preset| preset.name.clone()); + + widget::dialog() + .title(fl!("context-action-confirm-title", name = name)) + .body(fl!("context-action-confirm-warning", items = paths.len())) + .icon(icon::from_name("dialog-error").size(64)) + .primary_action( + widget::button::suggested(fl!("run")) + .on_press(Message::DialogComplete) + .id(CONFIRM_CONTEXT_ACTION_BUTTON_ID.clone()), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), + ) + } DialogPage::OpenWith { path, mime, @@ -6333,6 +6434,7 @@ impl Application for App { &self.key_binds, &self.modifiers, self.clipboard_has_content(), + &self.config.context_actions, ) .map(move |message| Message::TabMessage(Some(entity), message)); tab_column = tab_column.push(tab_view); @@ -6361,6 +6463,7 @@ impl Application for App { &self.key_binds, &window.modifiers, self.clipboard_has_content(), + &self.config.context_actions, ) .map(|x| Message::TabMessage(Some(*entity), x)), id.clone(), @@ -6378,6 +6481,7 @@ impl Application for App { &self.key_binds, &window.modifiers, self.clipboard_has_content(), + &self.config.context_actions, ) .map(move |message| Message::TabMessage(Some(*entity), message)), None => widget::space::vertical().into(), diff --git a/src/config.rs b/src/config.rs index cbe84a6..0ce9c22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,8 @@ use crate::{ tab::{HeadingOptions, Location, View}, }; +pub use crate::context_action::{ContextActionPreset, ContextActionSelection}; + pub const CONFIG_VERSION: u64 = 1; // Default icon sizes @@ -164,6 +166,7 @@ pub struct Config { pub app_theme: AppTheme, pub dialog: DialogConfig, pub desktop: DesktopConfig, + pub context_actions: Vec, pub thumb_cfg: ThumbCfg, pub favorites: Vec, pub show_details: bool, @@ -220,6 +223,7 @@ impl Default for Config { app_theme: AppTheme::System, desktop: DesktopConfig::default(), dialog: DialogConfig::default(), + context_actions: Vec::new(), thumb_cfg: ThumbCfg::default(), favorites: vec![ Favorite::Home, diff --git a/src/context_action.rs b/src/context_action.rs new file mode 100644 index 0000000..b12a48a --- /dev/null +++ b/src/context_action.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::{mime_app, spawn_detached::spawn_detached}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum ContextActionSelection { + #[default] + #[serde(alias = "any")] + Any, + #[serde(alias = "files")] + Files, + #[serde(alias = "folders")] + Folders, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +pub struct ContextActionPreset { + pub name: String, + pub confirm: bool, + pub selection: ContextActionSelection, + pub steps: Vec, +} + +impl ContextActionPreset { + pub fn matches_selection(&self, selected: usize, selected_dir: usize) -> bool { + if selected == 0 { + return false; + } + + match self.selection { + ContextActionSelection::Any => true, + ContextActionSelection::Files => selected_dir == 0, + ContextActionSelection::Folders => selected_dir == selected, + } + } + + pub fn run(&self, paths: &[PathBuf]) { + if self.steps.is_empty() { + log::warn!("context action {:?} has no steps", self.name); + return; + } + + for step in &self.steps { + let Some(commands) = mime_app::exec_to_command(step, paths) else { + log::warn!( + "failed to parse context action {:?}: invalid Exec {:?}", + self.name, + step + ); + return; + }; + + for mut command in commands { + if let Err(err) = spawn_detached(&mut command) { + log::warn!( + "failed to run context action {:?} step {:?}: {}", + self.name, + step, + err + ); + return; + } + } + } + } +} + +pub fn action_name(actions: &[ContextActionPreset], action: usize) -> Option { + actions.get(action).map(|preset| preset.name.clone()) +} + +pub fn run(actions: &[ContextActionPreset], action: usize, paths: &[PathBuf]) { + if let Some(preset) = actions.get(action) { + preset.run(paths); + } else { + log::warn!("invalid context action index `{action}`"); + } +} diff --git a/src/dialog.rs b/src/dialog.rs index a16b5f3..6bb9a0e 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -45,7 +45,7 @@ use crate::{ Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID, }, config::{Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch}, - fl, home_dir, + context_action, fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, menu, @@ -441,8 +441,17 @@ impl Dialog { #[derive(Clone, Debug)] enum DialogPage { - NewFolder { parent: PathBuf, name: String }, - Replace { filename: String }, + NewFolder { + parent: PathBuf, + name: String, + }, + RunContextAction { + action: usize, + paths: Box<[PathBuf]>, + }, + Replace { + filename: String, + }, } #[derive(Clone, Debug)] @@ -1203,6 +1212,21 @@ impl Application for App { .spacing(space_xxs), ) } + DialogPage::RunContextAction { action, paths } => { + let name = context_action::action_name(&self.flags.config.context_actions, *action) + .unwrap_or_else(|| fl!("context-action")); + + widget::dialog() + .title(fl!("context-action-confirm-title", name = name)) + .body(fl!("context-action-confirm-warning", items = paths.len())) + .icon(widget::icon::from_name("dialog-error").size(64)) + .primary_action( + widget::button::suggested(fl!("run")).on_press(Message::DialogComplete), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), + ) + } DialogPage::Replace { filename } => widget::dialog() .title(fl!("replace-title", filename = filename.as_str())) .icon(widget::icon::from_name("dialog-question").size(64)) @@ -1425,6 +1449,9 @@ impl Application for App { } } } + DialogPage::RunContextAction { action, paths } => { + context_action::run(&self.flags.config.context_actions, action, &paths); + } DialogPage::Replace { .. } => { return self.update(Message::Save(true)); } @@ -1831,6 +1858,7 @@ impl Application for App { &app.key_binds, &app.modifiers, false, // Paste not used in dialogs + &app.flags.config.context_actions, ) .map(Message::TabMessage) .map(cosmic::Action::App), @@ -1851,6 +1879,28 @@ impl Application for App { } } } + tab::Command::RunContextAction(action) => { + let paths: Box<[_]> = self + .tab + .selected_locations() + .into_iter() + .filter_map(Location::into_path_opt) + .collect(); + if let Some(preset) = self.flags.config.context_actions.get(action) { + if preset.confirm { + self.dialog_pages + .push_back(DialogPage::RunContextAction { action, paths }); + } else { + context_action::run( + &self.flags.config.context_actions, + action, + &paths, + ); + } + } else { + log::warn!("invalid context action index `{action}`"); + } + } tab::Command::Iced(iced_command) => { commands.push(iced_command.0.map(|tab_message| { cosmic::action::app(Message::TabMessage(tab_message)) @@ -2024,7 +2074,12 @@ impl Application for App { col = col.push( self.tab - .view(&self.key_binds, &self.modifiers, false) + .view( + &self.key_binds, + &self.modifiers, + false, + &self.flags.config.context_actions, + ) .map(Message::TabMessage), ); diff --git a/src/lib.rs b/src/lib.rs index 159af08..cc0bcc1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ use app::{App, Flags}; pub mod app; mod archive; pub mod clipboard; +mod context_action; use config::Config; pub mod config; pub mod dialog; diff --git a/src/menu.rs b/src/menu.rs index ffec707..8db61d7 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -20,7 +20,7 @@ use std::{collections::HashMap, sync::LazyLock}; use crate::{ app::{Action, Message}, - config::Config, + config::{Config, ContextActionPreset}, fl, tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab}, }; @@ -60,6 +60,7 @@ pub fn context_menu<'a>( key_binds: &HashMap, modifiers: &Modifiers, clipboard_paste_available: bool, + context_actions: &[ContextActionPreset], ) -> Element<'a, tab::Message> { let find_key = |action: &Action| -> String { for (key_bind, key_action) in key_binds { @@ -157,6 +158,14 @@ pub fn context_menu<'a>( selected_types.sort_unstable(); selected_types.dedup(); selected_trash_only = selected_trash_only && selected == 1; + let context_action_items = |selected: usize, selected_dir: usize| { + context_actions + .iter() + .enumerate() + .filter(|(_, action)| action.matches_selection(selected, selected_dir)) + .map(|(i, action)| menu_item(action.name.clone(), Action::RunContextAction(i)).into()) + .collect::>>() + }; // Parse the desktop entry if it is the only selection #[cfg(feature = "desktop")] let selected_desktop_entry = selected_desktop_entry.and_then(|path| { @@ -204,6 +213,11 @@ pub fn context_menu<'a>( } // Should this simply bypass trash and remove the shortcut? children.push(menu_item(fl!("move-to-trash"), Action::Delete).into()); + let action_items = context_action_items(selected, selected_dir); + if !action_items.is_empty() { + children.push(divider::horizontal::light().into()); + children.extend(action_items); + } } else if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); @@ -226,6 +240,11 @@ pub fn context_menu<'a>( children .push(menu_item(fl!("open-in-new-window"), Action::OpenInNewWindow).into()); } + let action_items = context_action_items(selected, selected_dir); + if !action_items.is_empty() { + children.push(divider::horizontal::light().into()); + children.extend(action_items); + } children.push(divider::horizontal::light().into()); if selected_mount_point == 0 { children.push(menu_item(fl!("rename"), Action::Rename).into()); diff --git a/src/tab.rs b/src/tab.rs index 08b2c45..578726f 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -66,7 +66,10 @@ use crate::{ FxOrderMap, app::{Action, PreviewItem, PreviewKind}, clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, - config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg}, + config::{ + ContextActionPreset, DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, + ThumbCfg, + }, dialog::DialogKind, fl, large_image::{ @@ -1794,6 +1797,7 @@ pub enum Command { OpenInNewWindow(PathBuf), OpenTrash, Preview(PreviewKind), + RunContextAction(usize), SetOpenWith(Mime, String), SetPermissions(PathBuf, u32), SetMultiplePermissions(Vec<(PathBuf, u32)>), @@ -1852,6 +1856,7 @@ pub enum Message { SelectFirst, SelectLast, SetOpenWith(Mime, String), + RunContextAction(usize), SetPermissions(PathBuf, u32), ShiftPermissions(Option<(PathBuf, u32)>, u32, u32), SetSort(HeadingOptions, bool), @@ -3566,6 +3571,11 @@ impl Tab { commands.push(Command::Action(action)); } + Message::RunContextAction(action) => { + self.context_menu = None; + + commands.push(Command::RunContextAction(action)); + } Message::ContextMenu(point_opt, _) => { self.edit_location = None; self.context_menu = point_opt; @@ -6083,6 +6093,7 @@ impl Tab { modifiers: &'a Modifiers, size: Size, clipboard_paste_available: bool, + context_actions: &'a [ContextActionPreset], ) -> Element<'a, Message> { // Update cached size self.size_opt.set(Some(size)); @@ -6170,8 +6181,13 @@ impl Tab { if let Some(point) = self.context_menu && (!cfg!(feature = "wayland") || !crate::is_wayland()) { - let context_menu = - menu::context_menu(self, key_binds, modifiers, clipboard_paste_available); + let context_menu = menu::context_menu( + self, + key_binds, + modifiers, + clipboard_paste_available, + context_actions, + ); popover = popover .popup(context_menu) .position(widget::popover::Position::Point(point)); @@ -6536,10 +6552,17 @@ impl Tab { key_binds: &'a HashMap, modifiers: &'a Modifiers, clipboard_paste_available: bool, + context_actions: &'a [ContextActionPreset], ) -> Element<'a, Message> { widget::responsive(move |size| { widget::id_container( - self.view_responsive(key_binds, modifiers, size, clipboard_paste_available), + self.view_responsive( + key_binds, + modifiers, + size, + clipboard_paste_available, + context_actions, + ), Id::new(format!( "tab-{}-{}", self.scrollable_id, self.location_title From ad0e66dceb4d0f21c2b1e02333490b33eeac18bf Mon Sep 17 00:00:00 2001 From: darkfated Date: Fri, 10 Apr 2026 02:58:20 +0300 Subject: [PATCH 2/2] Fix: remove context actions from open/save dialog --- src/context_action.rs | 4 ---- src/dialog.rs | 55 +++---------------------------------------- 2 files changed, 3 insertions(+), 56 deletions(-) diff --git a/src/context_action.rs b/src/context_action.rs index b12a48a..86f3bc6 100644 --- a/src/context_action.rs +++ b/src/context_action.rs @@ -70,10 +70,6 @@ impl ContextActionPreset { } } -pub fn action_name(actions: &[ContextActionPreset], action: usize) -> Option { - actions.get(action).map(|preset| preset.name.clone()) -} - pub fn run(actions: &[ContextActionPreset], action: usize, paths: &[PathBuf]) { if let Some(preset) = actions.get(action) { preset.run(paths); diff --git a/src/dialog.rs b/src/dialog.rs index 6bb9a0e..9fab965 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -45,7 +45,7 @@ use crate::{ Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID, }, config::{Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch}, - context_action, fl, home_dir, + fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, menu, @@ -445,10 +445,6 @@ enum DialogPage { parent: PathBuf, name: String, }, - RunContextAction { - action: usize, - paths: Box<[PathBuf]>, - }, Replace { filename: String, }, @@ -1212,21 +1208,6 @@ impl Application for App { .spacing(space_xxs), ) } - DialogPage::RunContextAction { action, paths } => { - let name = context_action::action_name(&self.flags.config.context_actions, *action) - .unwrap_or_else(|| fl!("context-action")); - - widget::dialog() - .title(fl!("context-action-confirm-title", name = name)) - .body(fl!("context-action-confirm-warning", items = paths.len())) - .icon(widget::icon::from_name("dialog-error").size(64)) - .primary_action( - widget::button::suggested(fl!("run")).on_press(Message::DialogComplete), - ) - .secondary_action( - widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), - ) - } DialogPage::Replace { filename } => widget::dialog() .title(fl!("replace-title", filename = filename.as_str())) .icon(widget::icon::from_name("dialog-question").size(64)) @@ -1449,9 +1430,6 @@ impl Application for App { } } } - DialogPage::RunContextAction { action, paths } => { - context_action::run(&self.flags.config.context_actions, action, &paths); - } DialogPage::Replace { .. } => { return self.update(Message::Save(true)); } @@ -1879,28 +1857,6 @@ impl Application for App { } } } - tab::Command::RunContextAction(action) => { - let paths: Box<[_]> = self - .tab - .selected_locations() - .into_iter() - .filter_map(Location::into_path_opt) - .collect(); - if let Some(preset) = self.flags.config.context_actions.get(action) { - if preset.confirm { - self.dialog_pages - .push_back(DialogPage::RunContextAction { action, paths }); - } else { - context_action::run( - &self.flags.config.context_actions, - action, - &paths, - ); - } - } else { - log::warn!("invalid context action index `{action}`"); - } - } tab::Command::Iced(iced_command) => { commands.push(iced_command.0.map(|tab_message| { cosmic::action::app(Message::TabMessage(tab_message)) @@ -2073,13 +2029,8 @@ impl Application for App { } col = col.push( - self.tab - .view( - &self.key_binds, - &self.modifiers, - false, - &self.flags.config.context_actions, - ) + self.tab + .view(&self.key_binds, &self.modifiers, false, &[]) .map(Message::TabMessage), );